pandorga 1.12.0 → 1.14.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.
@@ -171,26 +171,34 @@
171
171
  display: contents;
172
172
  }
173
173
 
174
- /* One geometry for all four. The border is currentColor rather than a fixed
175
- token, so each chip outlines itself in whatever ink it is already using and
176
- the row stays a family across both palettes. The shared category chip is a
177
- filled chip everywhere else on the site; here it outlines like its three
178
- neighbors, and its hover still fills — which is how a reader can tell that
179
- this one, alone, is a link. */
174
+ /* One geometry for all four. Facets lose their border and get a facet-strength
175
+ mark in their own ink. The tag chip is the only link and deepens its mark
176
+ on hover. */
180
177
  .source-facets .source-facet,
181
178
  .source-facets .ledger-chip,
182
179
  .source-facets .lang-pill {
183
180
  display: inline-flex;
184
181
  align-items: center;
185
182
  height: var(--source-facet-height);
186
- padding: 0 var(--space-sm);
187
- border: 1.5px solid color-mix(in srgb, currentColor 40%, var(--border-ink));
183
+ padding: 0 calc(var(--space-sm) + 1.5px);
184
+ border: 0;
188
185
  border-radius: var(--radius-card);
189
- background: transparent;
190
186
  white-space: nowrap;
191
187
  font-size: var(--type-label);
192
188
  }
193
189
 
190
+ .source-facets .ledger-chip {
191
+ --mark-1: var(--mark-facet-a);
192
+ --mark-2: var(--mark-facet-b);
193
+ --mark-3: var(--mark-facet-c);
194
+ }
195
+
196
+ .source-facets a.ledger-chip:hover {
197
+ --mark-1: var(--mark-hover-a);
198
+ --mark-2: var(--mark-hover-b);
199
+ --mark-3: var(--mark-hover-c);
200
+ }
201
+
194
202
  /* The two chips this page owns. The shared pill and chip bring their own
195
203
  type; these match it rather than inventing a second label voice. */
196
204
  .source-facet {
@@ -199,12 +207,19 @@
199
207
  font-weight: var(--weight-label);
200
208
  letter-spacing: 0.03em;
201
209
  line-height: 1;
202
- color: var(--color-on-surface-variant);
210
+ --facet-ink: var(--color-on-surface-variant);
211
+ color: color-mix(in srgb, var(--facet-ink) var(--facet-ink-mix), var(--color-on-surface));
212
+ --mark-ink: currentColor;
213
+ --mark-1: var(--mark-facet-a);
214
+ --mark-2: var(--mark-facet-b);
215
+ --mark-3: var(--mark-facet-c);
203
216
  }
204
217
 
205
- /* flag-icons sizes from the em, so the flag's own font-size is its height. */
218
+ /* flag-icons sizes from the em, so the flag's own font-size is its height.
219
+ The country chip gets no mark because the flag is its own shape. */
206
220
  .source-facet--country {
207
221
  padding-inline: 0.35rem;
222
+ background: none;
208
223
  }
209
224
 
210
225
  .source-facet--country .fi {
@@ -323,5 +338,5 @@ html[data-theme="light"] .source-card {
323
338
  /* The medium chip and the card edge read the same custom property, so they
324
339
  cannot drift apart: one colour, stated once, shown twice. */
325
340
  .source-facet--medium {
326
- color: var(--source-accent);
341
+ --facet-ink: var(--source-accent);
327
342
  }
@@ -207,16 +207,20 @@
207
207
  border-top: 1px solid var(--border-gentle-divider);
208
208
  }
209
209
 
210
+ /* Solid mark: opaque under the label, fades only in the trailing padding;
211
+ hover fills solid. */
210
212
  .writing-index .writing-lead-foot .writing-lead-cta {
211
213
  margin-left: auto;
212
214
  padding: 0.35rem var(--space-md);
213
- background: var(--color-primary);
215
+ background: linear-gradient(104deg, transparent 0.9%, var(--color-primary) 2.4%, var(--color-primary) calc(100% - 0.8rem), color-mix(in srgb, var(--color-primary) 40%, transparent) 100%);
216
+ -webkit-box-decoration-break: clone;
217
+ box-decoration-break: clone;
214
218
  color: var(--color-on-primary);
215
219
  font-weight: var(--weight-label);
216
220
  }
217
221
 
218
222
  .writing-index .writing-lead-cta:hover {
219
- background: color-mix(in srgb, var(--color-primary) 85%, var(--color-on-surface));
223
+ background: var(--color-primary);
220
224
  color: var(--color-on-primary);
221
225
  text-decoration: none;
222
226
  }
@@ -613,11 +617,10 @@ html[data-theme="light"] .writing-index .writing-lead-media .ledger-poster {
613
617
  }
614
618
 
615
619
  .writing-rail .listing-filter-chip {
620
+ align-self: flex-start;
616
621
  justify-content: flex-start;
617
- padding: 0.4rem 0.5rem;
618
- background: none;
622
+ padding: 0.4rem 0.5rem 0.4rem calc(0.5rem + 2px);
619
623
  border: 0;
620
- border-left: 2px solid transparent;
621
624
  color: var(--color-on-surface-variant);
622
625
  font-family: var(--font-label);
623
626
  font-size: var(--type-label-lg);
@@ -628,20 +631,12 @@ html[data-theme="light"] .writing-index .writing-lead-media .ledger-poster {
628
631
  }
629
632
 
630
633
  .writing-rail .listing-filter-chip:hover {
631
- background: var(--color-surface-high);
632
- border-left-color: var(--color-outline-variant);
633
634
  color: var(--color-on-surface);
634
635
  }
635
636
 
636
- /* The active state is carried by the page accent, the tinted fill and the rule
637
- on the left edge. Cabin (label role) can take real weight; colour still does
638
- the selected cue so the rail stays quiet. `--color-primary` follows
639
- `.page-accent--*` (green on Articles, ochre on Rascunhos), so the same rail
640
- markup does not stay locked to secondary. */
637
+ /* Active filter chip uses the shared highlighter mark without a left border rule or tinted fill. */
641
638
  .writing-rail .listing-filter-chip.is-active {
642
- background: color-mix(in srgb, var(--color-primary) 8%, transparent);
643
- border-left-color: var(--color-primary);
644
- color: var(--color-primary);
639
+ color: var(--color-on-surface);
645
640
  }
646
641
 
647
642
  /* ── Licence ──
data/lib/pandorga/cli.rb CHANGED
@@ -7,6 +7,7 @@ require_relative "commands/publish"
7
7
  require_relative "commands/install_functions"
8
8
  require_relative "commands/studio_schema"
9
9
  require_relative "commands/new"
10
+ require_relative "commands/thumbnails"
10
11
 
11
12
  module Pandorga
12
13
  # Dispatch for `exe/pandorga`.
@@ -18,7 +19,8 @@ module Pandorga
18
19
  "serve" => Commands::Serve,
19
20
  "publish" => Commands::Publish,
20
21
  "install-functions" => Commands::InstallFunctions,
21
- "studio-schema" => Commands::StudioSchema
22
+ "studio-schema" => Commands::StudioSchema,
23
+ "thumbnails" => Commands::Thumbnails
22
24
  }.freeze
23
25
 
24
26
  module_function
@@ -52,6 +54,7 @@ module Pandorga
52
54
  pandorga new PATH Scaffold a site from the minimal example
53
55
  pandorga doctor Check site config and environment
54
56
  pandorga export [src] [dest] Export content collections to JSON
57
+ pandorga thumbnails [root] Write 16:9 width variants for project thumbnails
55
58
  pandorga serve Local Jekyll serve with JSON re-export
56
59
  pandorga publish Sync content JSON to R2
57
60
  pandorga install-functions Copy functions/ and studio/ into site root
@@ -24,7 +24,7 @@ module Pandorga
24
24
 
25
25
  gem "pandorga", "~> 1.3"
26
26
  # Local checkout: gem "pandorga", path: "../pandorga"
27
- # GitHub tag: gem "pandorga", github: "alexandrelheinen/pandorga", tag: "v1.12.0"
27
+ # GitHub tag: gem "pandorga", github: "alexandrelheinen/pandorga", tag: "v1.14.0"
28
28
  gem "csv"
29
29
  gem "base64"
30
30
  RUBY
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require_relative "../thumbnail_variants"
5
+
6
+ module Pandorga
7
+ module Commands
8
+ # Generate 16:9 center-cropped width variants for local project thumbnails.
9
+ # Refs: PLT-AC-44
10
+ class Thumbnails
11
+ def run(argv)
12
+ site_root = Pathname.new(argv[0] || Dir.pwd).expand_path
13
+ summary = Pandorga::ThumbnailVariants.generate(site_root)
14
+ puts "pandorga thumbnails: #{summary[:written]} written, #{summary[:skipped]} skipped (#{summary[:sources]} sources in #{site_root})"
15
+ 0
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,267 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "fileutils"
5
+ require "open3"
6
+ require "pathname"
7
+ require "set"
8
+ require "time"
9
+ require "yaml"
10
+
11
+ require_relative "registry"
12
+
13
+ module Pandorga
14
+ # Generates 16:9 center-cropped width variants for local project thumbnails
15
+ # and computes their srcset strings.
16
+ # Refs: PLT-AC-44
17
+ module ThumbnailVariants
18
+ WIDTHS = [480, 960, 1440].freeze
19
+ IMAGE_EXTENSIONS = %w[.jpg .jpeg .png .webp].freeze
20
+ QUALITY = 82
21
+
22
+ module_function
23
+
24
+ def generate(site_root)
25
+ root = Pathname.new(site_root).expand_path
26
+ content_root = resolve_content_root(root)
27
+ sources = project_thumbnail_sources(root, content_root)
28
+
29
+ summary = { sources: sources.length, written: 0, skipped: 0, failed: 0 }
30
+ return summary if sources.empty?
31
+
32
+ bins = resolve_imagemagick
33
+ unless bins
34
+ warn "warning: ImageMagick (magick or convert) not found; skipping project thumbnail variants"
35
+ return summary
36
+ end
37
+
38
+ sources.each do |src|
39
+ src_width = identify_width(bins[:identify], src)
40
+ unless src_width && src_width.positive?
41
+ # An unreadable image keeps its original and must not stop the export.
42
+ warn "warning: cannot read #{src}; skipping its thumbnail variants"
43
+ summary[:failed] += 1
44
+ next
45
+ end
46
+
47
+ stem = src.basename(src.extname).to_s
48
+ variants_dir = src.dirname.join("variants")
49
+ src_mtime = src.mtime
50
+
51
+ WIDTHS.each do |width|
52
+ if width > src_width
53
+ summary[:skipped] += 1
54
+ next
55
+ end
56
+
57
+ dest = variants_dir.join("#{stem}-#{width}.jpg")
58
+ if dest.file? && dest.mtime >= src_mtime
59
+ summary[:skipped] += 1
60
+ next
61
+ end
62
+
63
+ begin
64
+ write_variant(bins[:convert], src, dest, width)
65
+ summary[:written] += 1
66
+ rescue StandardError => e
67
+ warn "warning: #{e.message.lines.first.to_s.strip}; keeping the original only"
68
+ summary[:failed] += 1
69
+ end
70
+ end
71
+ end
72
+
73
+ summary
74
+ end
75
+
76
+ def srcset_for(site_root, thumbnail_path)
77
+ media_rel = normalize_local_media_path(thumbnail_path)
78
+ return nil unless media_rel
79
+
80
+ ext = File.extname(media_rel).downcase
81
+ return nil unless IMAGE_EXTENSIONS.include?(ext)
82
+
83
+ root = Pathname.new(site_root).expand_path
84
+ content_root = resolve_content_root(root)
85
+ src = content_root.join(media_rel.delete_prefix("/"))
86
+ return nil unless src.file?
87
+
88
+ stem = src.basename(src.extname).to_s
89
+ variants_dir = src.dirname.join("variants")
90
+ return nil unless variants_dir.directory?
91
+
92
+ url_dir = File.dirname(media_rel)
93
+ parts = WIDTHS.filter_map do |width|
94
+ variant = variants_dir.join("#{stem}-#{width}.jpg")
95
+ next unless variant.file?
96
+
97
+ "#{url_dir}/variants/#{stem}-#{width}.jpg #{width}w"
98
+ end
99
+ return nil if parts.empty?
100
+
101
+ parts.join(", ")
102
+ end
103
+
104
+ def resolve_content_root(root)
105
+ candidate = root.join("content")
106
+ return candidate if candidate.directory?
107
+ return root if root.join("collections").directory? || root.join("media").directory?
108
+
109
+ candidate
110
+ end
111
+
112
+ def project_collections(root)
113
+ config_path = root.join("_config.yml")
114
+ config = if config_path.file?
115
+ YAML.safe_load(config_path.read, permitted_classes: [Date, Time], aliases: true) || {}
116
+ else
117
+ {}
118
+ end
119
+ pages = PandorgaRegistry.pages(config)
120
+ names = pages.filter_map do |page|
121
+ next unless page.is_a?(Hash) && page["template"].to_s == "portfolio"
122
+
123
+ col = page["collection"].to_s.strip
124
+ col = page.dig("collections", "projects").to_s.strip if col.empty? && page["collections"].is_a?(Hash)
125
+ col unless col.empty?
126
+ end.uniq
127
+ names.empty? ? ["projects"] : names
128
+ rescue StandardError
129
+ ["projects"]
130
+ end
131
+
132
+ def project_thumbnail_sources(root, content_root)
133
+ seen = Set.new
134
+ sources = []
135
+
136
+ project_collections(root).each do |collection|
137
+ dir = content_root.join("collections", collection)
138
+ next unless dir.directory?
139
+
140
+ dir.glob("**/*.{md,markdown}").sort.each do |md_path|
141
+ fm = read_front_matter(md_path)
142
+ next if fm["published"] == false && ENV["CONTENT_EXPORT_INCLUDE_UNPUBLISHED"] != "1"
143
+
144
+ media_rel = normalize_local_media_path(fm["thumbnail"])
145
+ next unless media_rel
146
+ next unless IMAGE_EXTENSIONS.include?(File.extname(media_rel).downcase)
147
+
148
+ src = content_root.join(media_rel.delete_prefix("/"))
149
+ next unless src.file?
150
+
151
+ key = src.expand_path.to_s
152
+ next if seen.include?(key)
153
+
154
+ seen << key
155
+ sources << src
156
+ end
157
+ end
158
+
159
+ sources
160
+ end
161
+
162
+ def normalize_local_media_path(path)
163
+ value = path.to_s.strip
164
+ return nil if value.empty?
165
+ return nil if value.match?(%r{\Ahttps?://}i)
166
+
167
+ if value.start_with?("/content/media/")
168
+ "/media/#{value.delete_prefix("/content/media/")}"
169
+ elsif value.start_with?("content/media/")
170
+ "/media/#{value.delete_prefix("content/media/")}"
171
+ elsif value.start_with?("/media/")
172
+ value
173
+ elsif value.start_with?("media/")
174
+ "/#{value}"
175
+ end
176
+ end
177
+
178
+ def read_front_matter(path)
179
+ text = path.read.sub(/\A\xEF\xBB\xBF/, "")
180
+ return {} unless text.start_with?("---\n") || text.start_with?("---\r\n")
181
+
182
+ parts = text.split(/^---\s*$\r?\n?/, 3)
183
+ return {} unless parts.length == 3
184
+
185
+ YAML.safe_load(parts[1], permitted_classes: [Date, Time], aliases: true) || {}
186
+ rescue StandardError
187
+ {}
188
+ end
189
+
190
+ def resolve_imagemagick
191
+ override = ENV["PANDORGA_IMAGEMAGICK"].to_s.strip
192
+ unless override.empty?
193
+ return nil unless executable_available?(override)
194
+
195
+ return {
196
+ convert: [override],
197
+ identify: File.basename(override) == "magick" ? [override, "identify"] : [ resolve_companion_identify(override) ]
198
+ }
199
+ end
200
+
201
+ if executable_available?("magick")
202
+ { convert: ["magick"], identify: ["magick", "identify"] }
203
+ elsif executable_available?("convert") && executable_available?("identify")
204
+ { convert: ["convert"], identify: ["identify"] }
205
+ end
206
+ end
207
+
208
+ def resolve_companion_identify(convert_bin)
209
+ sibling = File.join(File.dirname(convert_bin), "identify")
210
+ return sibling if File.executable?(sibling) && !File.directory?(sibling)
211
+
212
+ "identify"
213
+ end
214
+
215
+ def executable_available?(bin)
216
+ return false if bin.to_s.empty?
217
+
218
+ if bin.include?("/")
219
+ File.executable?(bin) && !File.directory?(bin)
220
+ else
221
+ ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? do |dir|
222
+ next false if dir.empty?
223
+
224
+ candidate = File.join(dir, bin)
225
+ File.executable?(candidate) && !File.directory?(candidate)
226
+ end
227
+ end
228
+ end
229
+
230
+ def identify_width(identify_cmd, src)
231
+ cmd = [*identify_cmd, "-ping", "-format", "%w", "#{src}[0]"]
232
+ out, _err, status = Open3.capture3(*cmd)
233
+ return nil unless status.success?
234
+
235
+ out.to_s.strip.to_i
236
+ rescue StandardError
237
+ nil
238
+ end
239
+
240
+ def write_variant(convert_cmd, src, dest, width)
241
+ height = (width * 9) / 16
242
+ FileUtils.mkdir_p(dest.dirname)
243
+ tmp = dest.dirname.join(".#{dest.basename}.tmp-#{Process.pid}")
244
+ cmd = [
245
+ *convert_cmd,
246
+ "#{src}[0]",
247
+ "-auto-orient",
248
+ "-resize", "#{width}x#{height}^",
249
+ "-gravity", "center",
250
+ "-extent", "#{width}x#{height}",
251
+ "-strip",
252
+ "-interlace", "JPEG",
253
+ "-sampling-factor", "4:2:0",
254
+ "-quality", QUALITY.to_s,
255
+ "jpeg:#{tmp}"
256
+ ]
257
+ out, err, status = Open3.capture3(*cmd)
258
+ unless status.success? && tmp.file?
259
+ FileUtils.rm_f(tmp)
260
+ raise "ImageMagick failed for #{src} (#{width}w): #{out}\n#{err}"
261
+ end
262
+ File.rename(tmp.to_s, dest.to_s)
263
+ ensure
264
+ FileUtils.rm_f(tmp) if tmp && tmp.exist?
265
+ end
266
+ end
267
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pandorga
4
- VERSION = "1.12.0"
4
+ VERSION = "1.14.0"
5
5
  end
data/lib/pandorga.rb CHANGED
@@ -14,6 +14,7 @@ module Pandorga
14
14
  end
15
15
 
16
16
  require_relative "pandorga/registry"
17
+ require_relative "pandorga/thumbnail_variants"
17
18
 
18
19
  # Jekyll loads this file as a plugin when `plugins: [pandorga]`.
19
20
  require_relative "pandorga/jekyll" if defined?(Jekyll)
@@ -21,6 +21,7 @@ require_relative "../lib/content_license"
21
21
  require_relative "../lib/writing_index_contract"
22
22
  require_relative "band_art"
23
23
  require_relative "hero_portraits"
24
+ require_relative "../../lib/pandorga/thumbnail_variants"
24
25
 
25
26
  REPO_ROOT = Pathname.new(ENV.fetch("PANDORGA_SITE_ROOT", Dir.pwd)).expand_path
26
27
  CONTENT_ROOT = Pathname.new(ARGV[0] || "content").expand_path
@@ -121,6 +122,16 @@ def rewrite_media_references(text)
121
122
  rewrite_media_attribute_paths(rewrite_markdown_media_paths(text))
122
123
  end
123
124
 
125
+ def rewrite_srcset_media_urls(srcset)
126
+ return nil unless srcset.is_a?(String) && !srcset.strip.empty?
127
+
128
+ srcset.split(",").map do |candidate|
129
+ url, descriptor = candidate.strip.split(/\s+/, 2)
130
+ resolved = resolve_media_url(url)
131
+ descriptor ? "#{resolved} #{descriptor}" : resolved
132
+ end.join(", ")
133
+ end
134
+
124
135
  def rewrite_front_matter_media!(front_matter)
125
136
  return front_matter unless front_matter.is_a?(Hash)
126
137
 
@@ -360,6 +371,11 @@ def export_markdown_tree(source_dir, output_dir, type)
360
371
  end
361
372
 
362
373
  apply_git_chronology!(front_matter, repo_relative_path, type)
374
+ if type == "project"
375
+ raw_srcset = Pandorga::ThumbnailVariants.srcset_for(REPO_ROOT, front_matter["thumbnail"])
376
+ rewritten_srcset = rewrite_srcset_media_urls(raw_srcset)
377
+ front_matter["thumbnail_srcset"] = rewritten_srcset if rewritten_srcset
378
+ end
363
379
  rewrite_front_matter_media!(front_matter)
364
380
 
365
381
  if ContentKeys::KEYED_TYPES.include?(type)
@@ -594,7 +610,7 @@ def writing_index_entry_from_export(payload, content_root, collection)
594
610
  end
595
611
 
596
612
  legacy = File.basename(source_path, File.extname(source_path))
597
- {
613
+ entry = {
598
614
  "slug" => slug,
599
615
  "key" => key,
600
616
  "title" => title,
@@ -630,6 +646,9 @@ def writing_index_entry_from_export(payload, content_root, collection)
630
646
  "path_segment" => slug
631
647
  )
632
648
  }
649
+ exported_srcset = payload.dig("front_matter", "thumbnail_srcset").to_s
650
+ entry["thumbnail_srcset"] = exported_srcset unless exported_srcset.empty?
651
+ entry
633
652
  end
634
653
 
635
654
  def export_derived_data(content_root, output_root)
@@ -710,6 +729,8 @@ def export_collection_trees
710
729
  trees
711
730
  end
712
731
 
732
+ Pandorga::ThumbnailVariants.generate(REPO_ROOT)
733
+
713
734
  files = export_collection_trees
714
735
  files["page_fragments"] = export_page_fragments(CONTENT_ROOT.join("pages"), OUTPUT_ROOT.join("pages"))
715
736
  files["page_headers"] = export_page_headers(CONTENT_ROOT, OUTPUT_ROOT) << export_hero_portraits(CONTENT_ROOT, OUTPUT_ROOT)