pandorga 1.11.0 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9d3adc84d250c4ff140df5b9cc9db266896fdfce92fd899789fd943ce8ac4659
4
- data.tar.gz: 474d788dea7851796fb189732c9bb75d118666177f4cf81df70d8361f4310872
3
+ metadata.gz: f0650221add1248fcfa882ccf2557c9d73d1de757239e6e396e8cec1ddefa512
4
+ data.tar.gz: 2c6502ac5c6fdd2d6153490b88925e465b7c66f315502e61819b6541e530831b
5
5
  SHA512:
6
- metadata.gz: d9e76b0be8485c3870366cce0bc816fc55f3dee7ea2b17ec40602f2c70dccbf985e2d7be6da7911e54a7f830c6fd3c4030dbb985689d50c84cb9226c04cd0f2e
7
- data.tar.gz: 6853fe1a1d0edd4066d45187a4cddd67258a753afd849284615383fb9cae4844a24aabff188681a73066e2808170bfc1e28730832e68d976c34400899d2cea88
6
+ metadata.gz: 9123d4ada63311688340fa8da4076a898dfb362764d15030da03cd62776def8172e9be28fc9d7fe47005930c004565860f8997ca46050c7149eb76332c94fa6d
7
+ data.tar.gz: 6dbf48af21a4d6e56a0b21e070a951f8180424de1efbb906830d435200d736b4955781d0f04b3f7677be32cf1ce20a916bdad761faf41c6fd836ac48f06e1aa4
data/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [1.12.0] — 2026-10-19
6
+
7
+ ### Changed
8
+
9
+ - `pandorga install-functions` fills the Studio tag pickers from the site's `_config.yml` → `pandorga.taxonomies` (with `min` and `max`), falling back to a page's `taxonomy`, then applies `studio.overrides.yml`. The gem's `studio/schema.yml` no longer ships a site's tag vocabulary; a collection without one gets an empty picker and a build warning (PLT-AC-43).
10
+
5
11
  ## [1.11.0] — 2026-10-19
6
12
 
7
13
  ### Fixed
data/DESIGN.md CHANGED
@@ -1201,14 +1201,12 @@ discs.
1201
1201
  An element renders only if a populated field backs it. The classification axes,
1202
1202
  as enforced by [`_plugins/lib/tag_validator.rb`](_plugins/lib/tag_validator.rb):
1203
1203
 
1204
- * **Articles** classify on `tags`, one to three, from a closed English
1205
- vocabulary: `Aesthetics`, `AI`, `Data`, `DevOps`, `Mathematics`,
1206
- `Robotics`, `Social`. All 22 carry them.
1207
- * **Rascunhos** classify on `tags` too, by subject and in Portuguese, **zero to
1208
- three**: `arte`, `brasilidade`, `carme`, `devaneio`, `engraçadinho`,
1209
- `música`, `pesquisa`, `provocação`, `técnico`, `trabalho`. Unlike articles
1210
- the field may be empty — a notebook holds pages that file nowhere. 29 of 30
1211
- carry at least one.
1204
+ * **Articles** classify on `tags`, one to three, from the closed vocabulary
1205
+ the site declares in `_config.yml` → `pandorga.taxonomies`.
1206
+ * **Rascunhos** classify on `tags` too, **zero to three**, from the closed
1207
+ vocabulary the site declares in `_config.yml` → `pandorga.taxonomies`.
1208
+ Unlike articles the field may be empty — a notebook holds pages that file
1209
+ nowhere.
1212
1210
  * `category` is rejected on both collections — one classification axis, not
1213
1211
  two.
1214
1212
  * **Projects** carry free `tags` (all 8 do), plus `label`, `status`,
@@ -1,12 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "date"
3
4
  require "fileutils"
4
5
  require "pathname"
6
+ require "yaml"
7
+ require "pandorga/commands/studio_schema"
5
8
 
6
9
  module Pandorga
7
10
  module Commands
8
11
  # Option A (PLT-0.2): copy functions/ and studio/ from the gem into the site root
9
- # so Cloudflare Pages can compile Functions from the project root.
12
+ # so Cloudflare Pages can compile Functions from the project root, then apply
13
+ # site taxonomies and overrides to studio/schema.yml (PLT-AC-43).
10
14
  class InstallFunctions
11
15
  STAMP = ".pandorga-version"
12
16
 
@@ -22,6 +26,7 @@ module Pandorga
22
26
 
23
27
  copy_tree(gem_functions, dest_root.join("functions"))
24
28
  copy_tree(gem_studio, dest_root.join("studio")) if gem_studio.directory?
29
+ hydrate_studio_schema(dest_root)
25
30
 
26
31
  stamp = dest_root.join("functions", STAMP)
27
32
  stamp.write("#{Pandorga::VERSION}\n")
@@ -36,6 +41,36 @@ module Pandorga
36
41
  FileUtils.mkdir_p(dest.dirname)
37
42
  FileUtils.cp_r(src, dest)
38
43
  end
44
+
45
+ def hydrate_studio_schema(dest_root)
46
+ config_path = dest_root.join("_config.yml")
47
+ schema_path = dest_root.join("studio", "schema.yml")
48
+ return unless config_path.file? && schema_path.file?
49
+
50
+ config = load_yaml(config_path)
51
+ schema = load_yaml(schema_path)
52
+ return unless schema.is_a?(Hash)
53
+
54
+ missing = StudioSchema.apply_taxonomies!(schema, config)
55
+
56
+ overrides_path = dest_root.join("studio.overrides.yml")
57
+ if overrides_path.file?
58
+ extra = load_yaml(overrides_path)
59
+ StudioSchema.apply_overrides!(schema, extra) if extra.is_a?(Hash)
60
+ end
61
+
62
+ header = "# Installed by pandorga install-functions #{Pandorga::VERSION}; " \
63
+ "tag vocabularies come from _config.yml pandorga.taxonomies.\n"
64
+ schema_path.write("#{header}#{YAML.dump(schema)}")
65
+
66
+ missing.each do |name|
67
+ warn "pandorga install-functions: warning: no taxonomy for #{name}; its Studio tags picker is empty"
68
+ end
69
+ end
70
+
71
+ def load_yaml(path)
72
+ YAML.safe_load(path.read, permitted_classes: [Date, Time], aliases: true) || {}
73
+ end
39
74
  end
40
75
  end
41
76
  end
@@ -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.11.0"
27
+ # GitHub tag: gem "pandorga", github: "alexandrelheinen/pandorga", tag: "v1.12.0"
28
28
  gem "csv"
29
29
  gem "base64"
30
30
  RUBY
@@ -8,7 +8,7 @@ require "date"
8
8
  module Pandorga
9
9
  module Commands
10
10
  # Compose studio/schema.yml from template studio.yml files + registry.
11
- # Refs: PLT-AC-3, docs/spec.md §5.5
11
+ # Refs: PLT-AC-3, PLT-AC-43, docs/spec.md §5.5
12
12
  class StudioSchema
13
13
  DEFAULT_MEDIA = {
14
14
  "name" => "media",
@@ -28,6 +28,14 @@ module Pandorga
28
28
  "options" => { "format" => "markdown" }
29
29
  }.freeze
30
30
 
31
+ def self.apply_taxonomies!(schema, config)
32
+ new.apply_taxonomies!(schema, config)
33
+ end
34
+
35
+ def self.apply_overrides!(schema, extra)
36
+ new.apply_overrides!(schema, extra)
37
+ end
38
+
31
39
  def run(argv)
32
40
  root = Pathname.new(argv[0] || Dir.pwd).expand_path
33
41
  templates_root = Pathname.new(Pandorga.gem_path("templates"))
@@ -56,7 +64,6 @@ module Pandorga
56
64
  # Public for gates: build the schema hash without writing.
57
65
  def compose(config, templates_root)
58
66
  pages = config.dig("pandorga", "pages") || []
59
- taxonomies = config.dig("pandorga", "taxonomies") || {}
60
67
  groups = {}
61
68
  group_order = []
62
69
  needs_media = false
@@ -76,7 +83,7 @@ module Pandorga
76
83
  next unless fragment.is_a?(Hash)
77
84
 
78
85
  needs_media ||= truthy?(fragment["needs_media"])
79
- contributions = expand_fragment(fragment, page, taxonomies)
86
+ contributions = expand_fragment(fragment, page)
80
87
  contributions.each do |contrib|
81
88
  group_name = contrib[:group]
82
89
  unless groups.key?(group_name)
@@ -110,12 +117,88 @@ module Pandorga
110
117
  if needs_markdown
111
118
  schema["components"] = { "markdown_body" => deep_dup(DEFAULT_MARKDOWN_BODY) }
112
119
  end
120
+ apply_taxonomies!(schema, config)
113
121
  schema
114
122
  end
115
123
 
124
+ def apply_taxonomies!(schema, config)
125
+ return [] unless schema.is_a?(Hash)
126
+
127
+ vocabularies = resolve_taxonomies(config)
128
+ missing = []
129
+
130
+ Array(schema["content"]).each do |group|
131
+ next unless group.is_a?(Hash)
132
+
133
+ Array(group["items"]).each do |item|
134
+ next unless item.is_a?(Hash)
135
+ next unless item["type"].to_s == "collection"
136
+
137
+ name = item["name"].to_s
138
+ next if name.empty?
139
+
140
+ had_picker = apply_collection_taxonomy!(item, vocabularies[name])
141
+ missing << name if had_picker && !vocabularies.key?(name)
142
+ end
143
+ end
144
+
145
+ index = schema["collections"].is_a?(Hash) ? schema["collections"] : {}
146
+ index.each do |key, entry|
147
+ next unless entry.is_a?(Hash)
148
+
149
+ name = entry["name"].to_s.empty? ? key.to_s : entry["name"].to_s
150
+ next if name.empty?
151
+
152
+ had_picker = apply_collection_taxonomy!(entry, vocabularies[name])
153
+ missing << name if had_picker && !vocabularies.key?(name)
154
+ end
155
+
156
+ missing.uniq
157
+ end
158
+
159
+ def apply_overrides!(schema, extra)
160
+ if extra["media"].is_a?(Hash)
161
+ schema["media"] = deep_merge(schema["media"] || {}, extra["media"])
162
+ end
163
+
164
+ if extra["components"].is_a?(Hash)
165
+ schema["components"] = deep_merge(schema["components"] || {}, extra["components"])
166
+ end
167
+
168
+ if extra["collections"].is_a?(Hash)
169
+ extra["collections"].each do |name, patch|
170
+ next unless patch.is_a?(Hash)
171
+
172
+ item = find_content_item(schema, name.to_s)
173
+ if item
174
+ merged = deep_merge(item, patch)
175
+ item.replace(merged)
176
+ end
177
+ if schema["collections"].is_a?(Hash) && schema["collections"][name.to_s]
178
+ schema["collections"][name.to_s] =
179
+ deep_merge(schema["collections"][name.to_s], patch)
180
+ end
181
+ end
182
+ end
183
+
184
+ return unless extra["content"].is_a?(Array)
185
+
186
+ schema["content"] ||= []
187
+ extra["content"].each do |group|
188
+ next unless group.is_a?(Hash)
189
+
190
+ existing = schema["content"].find { |g| g["name"].to_s == group["name"].to_s }
191
+ if existing
192
+ existing.replace(deep_merge(existing, group))
193
+ else
194
+ schema["content"] << deep_dup(group)
195
+ end
196
+ end
197
+ end
198
+
116
199
  private
117
200
 
118
- def expand_fragment(fragment, page, taxonomies)
201
+ def expand_fragment(fragment, page)
119
202
  contributions = []
120
203
  default_group = fragment["group"].to_s
121
204
  default_group = page["template"].to_s if default_group.empty?
@@ -127,12 +210,7 @@ module Pandorga
127
210
  if fragment["collection"].is_a?(Hash)
128
211
  name = page["collection"].to_s
129
212
  unless name.empty?
130
- items << instantiate_collection(
131
- fragment["collection"],
132
- name,
133
- page,
134
- taxonomies
135
- )
213
+ items << instantiate_collection(fragment["collection"], name, page)
136
214
  end
137
215
  end
138
216
 
@@ -145,7 +223,7 @@ module Pandorga
145
223
  name = slot.to_s if name.empty?
146
224
  next if name.empty? || name == "data" || name == "profile"
147
225
 
148
- items << instantiate_collection(spec, name, page, taxonomies)
226
+ items << instantiate_collection(spec, name, page)
149
227
  end
150
228
  end
151
229
 
@@ -178,7 +256,7 @@ module Pandorga
178
256
  contributions
179
257
  end
180
258
 
181
- def instantiate_collection(spec, name, page, taxonomies)
259
+ def instantiate_collection(spec, name, page)
182
260
  entry = deep_dup(spec)
183
261
  entry["name"] = name
184
262
  entry["type"] ||= "collection"
@@ -189,66 +267,96 @@ module Pandorga
189
267
  end
190
268
  entry["path"] ||= "content/collections/#{name}"
191
269
  entry["path"] = entry["path"].to_s.gsub("{collection}", name)
192
- apply_taxonomy!(entry, name, taxonomies)
193
270
  entry
194
271
  end
195
272
 
196
- def apply_taxonomy!(entry, collection_name, taxonomies)
197
- tax = taxonomies[collection_name]
198
- return unless tax.is_a?(Hash)
273
+ def resolve_taxonomies(config)
274
+ return {} unless config.is_a?(Hash)
199
275
 
200
- Array(entry["fields"]).each do |field|
201
- next unless field.is_a?(Hash)
202
- next unless field["name"].to_s == "tags"
276
+ vocab = {}
277
+ pages = Array(config.dig("pandorga", "pages")).select { |p| p.is_a?(Hash) }
278
+ taxonomies = config.dig("pandorga", "taxonomies")
203
279
 
204
- field["options"] ||= {}
205
- next unless field["options"].is_a?(Hash)
280
+ if taxonomies.is_a?(Hash)
281
+ taxonomies.each do |collection, spec|
282
+ next unless spec.is_a?(Hash)
206
283
 
207
- tags = tax["tags"]
208
- field["options"]["values"] = tags if tags.is_a?(Array) && !tags.empty?
209
- max = tax["max"]
210
- field["options"]["max"] = max if max
211
- end
212
- end
284
+ tags = spec["tags"]
285
+ next unless tags.is_a?(Array) && !tags.empty?
213
286
 
214
- def apply_overrides!(schema, extra)
215
- if extra["media"].is_a?(Hash)
216
- schema["media"] = deep_merge(schema["media"] || {}, extra["media"])
217
- end
287
+ coll_name = collection.to_s
288
+ page_tax = pages.find { |p| p["collection"].to_s == coll_name }&.fetch("taxonomy", nil)
289
+ page_tax = nil unless page_tax.is_a?(Hash)
218
290
 
219
- if extra["components"].is_a?(Hash)
220
- schema["components"] = deep_merge(schema["components"] || {}, extra["components"])
291
+ max = spec["max"].nil? ? page_tax&.fetch("max", nil) : spec["max"]
292
+ min = spec["min"].nil? ? page_tax&.fetch("min", nil) : spec["min"]
293
+
294
+ vocab[coll_name] = build_taxonomy_entry(tags, max: max, min: min)
295
+ end
221
296
  end
222
297
 
223
- if extra["collections"].is_a?(Hash)
224
- extra["collections"].each do |name, patch|
225
- next unless patch.is_a?(Hash)
298
+ pages.each do |page|
299
+ coll_name = page["collection"].to_s
300
+ next if coll_name.empty? || vocab.key?(coll_name)
226
301
 
227
- item = find_content_item(schema, name.to_s)
228
- if item
229
- merged = deep_merge(item, patch)
230
- item.replace(merged)
231
- end
232
- if schema["collections"].is_a?(Hash) && schema["collections"][name.to_s]
233
- schema["collections"][name.to_s] =
234
- deep_merge(schema["collections"][name.to_s], patch)
235
- end
236
- end
302
+ spec = page["taxonomy"]
303
+ next unless spec.is_a?(Hash)
304
+
305
+ tags = spec["tags"]
306
+ next unless tags.is_a?(Array) && !tags.empty?
307
+
308
+ vocab[coll_name] = build_taxonomy_entry(tags, max: spec["max"], min: spec["min"])
237
309
  end
238
310
 
239
- return unless extra["content"].is_a?(Array)
311
+ vocab
312
+ end
240
313
 
241
- schema["content"] ||= []
242
- extra["content"].each do |group|
243
- next unless group.is_a?(Hash)
314
+ def build_taxonomy_entry(tags, max:, min:)
315
+ entry = { "tags" => tags.map(&:to_s) }
316
+ parsed_max = parse_int_or_nil(max)
317
+ parsed_min = parse_int_or_nil(min)
318
+ entry["max"] = parsed_max unless parsed_max.nil?
319
+ entry["min"] = parsed_min unless parsed_min.nil?
320
+ entry
321
+ end
244
322
 
245
- existing = schema["content"].find { |g| g["name"].to_s == group["name"].to_s }
246
- if existing
247
- existing.replace(deep_merge(existing, group))
323
+ def parse_int_or_nil(val)
324
+ return nil if val.nil?
325
+
326
+ Integer(val)
327
+ rescue ArgumentError, TypeError
328
+ nil
329
+ end
330
+
331
+ def apply_collection_taxonomy!(entry, tax)
332
+ had_picker = false
333
+ Array(entry["fields"]).each do |field|
334
+ next unless tag_picker_field?(field)
335
+
336
+ had_picker = true
337
+ field["options"] = {} unless field["options"].is_a?(Hash)
338
+ opts = field["options"]
339
+
340
+ if tax.is_a?(Hash)
341
+ opts["values"] = Array(tax["tags"]).map(&:to_s)
342
+ opts["max"] = tax["max"] unless tax["max"].nil?
343
+ if tax["min"].nil?
344
+ opts.delete("min")
345
+ else
346
+ opts["min"] = tax["min"]
347
+ end
248
348
  else
249
- schema["content"] << deep_dup(group)
349
+ opts["values"] = []
350
+ opts.delete("min")
250
351
  end
251
352
  end
353
+ had_picker
354
+ end
355
+
356
+ def tag_picker_field?(field)
357
+ field.is_a?(Hash) &&
358
+ field["name"].to_s == "tags" &&
359
+ field["type"].to_s == "select"
252
360
  end
253
361
 
254
362
  def find_content_item(schema, name)
@@ -79,7 +79,7 @@ taxonomies:
79
79
  prose: |
80
80
  Closed tag vocabularies per collection (`tags`, `max`, `min`). Page-local
81
81
  `pandorga.pages[].taxonomy` is also accepted when a collection has no top-level
82
- entry.
82
+ entry. `pandorga install-functions` copies these vocabularies into the installed Studio schema (`studio/schema.yml`), so the Studio tag pickers always offer this list. A collection with no vocabulary gets an empty picker and a warning.
83
83
 
84
84
  pages:
85
85
  title: "`pandorga.pages[]`"
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pandorga
4
- VERSION = "1.11.0"
4
+ VERSION = "1.12.0"
5
5
  end
@@ -0,0 +1,9 @@
1
+ pandorga:
2
+ pages:
3
+ - key: articles
4
+ template: articles
5
+ collection: articles
6
+ path: /pages/articles/
7
+ taxonomy:
8
+ tags: [Notes]
9
+ max: 1
@@ -0,0 +1,9 @@
1
+ pandorga:
2
+ taxonomies:
3
+ articles:
4
+ tags: [Design, Organizations]
5
+ max: 2
6
+ min: 1
7
+ posts:
8
+ tags: [alpha, beta, gamma]
9
+ max: 3
@@ -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"
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
data/studio/schema.yml CHANGED
@@ -7,6 +7,7 @@
7
7
  # - Thumbnail / image fields accept HTTPS URLs or /media/… paths (hotlink OK).
8
8
  # - Calendar days use type date with format yyyy-MM-dd
9
9
  # - Form order: identity (key, title, description) → options → body last
10
+ # Tag vocabularies are empty here: install-functions fills them from the site's _config.yml (PLT-AC-43).
10
11
 
11
12
  media:
12
13
  name: media
@@ -72,19 +73,7 @@ content:
72
73
  options:
73
74
  multiple: true
74
75
  max: 3
75
- values:
76
- [
77
- arte,
78
- brasilidade,
79
- carme,
80
- devaneio,
81
- engraçadinho,
82
- música,
83
- pesquisa,
84
- provocação,
85
- técnico,
86
- trabalho,
87
- ]
76
+ values: []
88
77
  - name: language
89
78
  label: Language
90
79
  type: select
@@ -170,10 +159,8 @@ content:
170
159
  description: "One to three."
171
160
  options:
172
161
  multiple: true
173
- min: 1
174
162
  max: 3
175
- values:
176
- [Aesthetics, AI, Data, DevOps, Mathematics, Robotics, Social]
163
+ values: []
177
164
  - name: language
178
165
  label: Language
179
166
  type: select
@@ -441,7 +428,7 @@ content:
441
428
  - name: label
442
429
  label: Label
443
430
  type: string
444
- description: "Small caps line above the project name (e.g. Robotics)."
431
+ description: "Small caps line above the project name."
445
432
  - name: icon
446
433
  label: Icon
447
434
  type: string
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.11.0
4
+ version: 1.12.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - The Pandorga contributors
@@ -353,6 +353,8 @@ files:
353
353
  - scripts/tailwind/tailwind.config.js
354
354
  - scripts/test/fixtures/dual-template/_config.yml
355
355
  - scripts/test/fixtures/dual-template/index.html
356
+ - scripts/test/fixtures/studio-taxonomy/page-local/_config.yml
357
+ - scripts/test/fixtures/studio-taxonomy/top-level/_config.yml
356
358
  - scripts/test/render-parity/article-note.json
357
359
  - scripts/test/render-parity/blockquote.json
358
360
  - scripts/test/render-parity/blocks/ansi-basic.json
@@ -467,6 +469,7 @@ files:
467
469
  - scripts/test/test-studio-rewrite-sanitize.mjs
468
470
  - scripts/test/test-studio-schema-composition.rb
469
471
  - scripts/test/test-studio-slug-config.rb
472
+ - scripts/test/test-studio-taxonomy.rb
470
473
  - scripts/test/test-template-packages.rb
471
474
  - scripts/test/test-text-excerpt.rb
472
475
  - scripts/test/test-theme-contrast.rb