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 +4 -4
- data/CHANGELOG.md +6 -0
- data/DESIGN.md +6 -8
- 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/registry/schema.yml +1 -1
- data/lib/pandorga/version.rb +1 -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-studio-taxonomy.rb +246 -0
- data/scripts/validate.sh +1 -0
- data/studio/schema.yml +4 -17
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f0650221add1248fcfa882ccf2557c9d73d1de757239e6e396e8cec1ddefa512
|
|
4
|
+
data.tar.gz: 2c6502ac5c6fdd2d6153490b88925e465b7c66f315502e61819b6541e530831b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
197
|
-
|
|
198
|
-
return unless tax.is_a?(Hash)
|
|
273
|
+
def resolve_taxonomies(config)
|
|
274
|
+
return {} unless config.is_a?(Hash)
|
|
199
275
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
276
|
+
vocab = {}
|
|
277
|
+
pages = Array(config.dig("pandorga", "pages")).select { |p| p.is_a?(Hash) }
|
|
278
|
+
taxonomies = config.dig("pandorga", "taxonomies")
|
|
203
279
|
|
|
204
|
-
|
|
205
|
-
|
|
280
|
+
if taxonomies.is_a?(Hash)
|
|
281
|
+
taxonomies.each do |collection, spec|
|
|
282
|
+
next unless spec.is_a?(Hash)
|
|
206
283
|
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
220
|
-
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
311
|
+
vocab
|
|
312
|
+
end
|
|
240
313
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
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[]`"
|
data/lib/pandorga/version.rb
CHANGED
|
@@ -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
|
|
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.
|
|
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
|