pandorga 1.10.2 → 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: 4e66f241523945a85596363141b9693e01ac61e5d6eec0771366ced0ef2dc8b6
4
- data.tar.gz: 7f00fe9a24f801f42065b3b80d760ccdc1b57fc6c46d89664e2f4e654168a545
3
+ metadata.gz: f0650221add1248fcfa882ccf2557c9d73d1de757239e6e396e8cec1ddefa512
4
+ data.tar.gz: 2c6502ac5c6fdd2d6153490b88925e465b7c66f315502e61819b6541e530831b
5
5
  SHA512:
6
- metadata.gz: 92e01266bf8f8f0c3a59f47596dd2d589dbd357d361471ecd59fba91d1e44b172f9c7eb3bc9b4958e0a27e8e5cb191637fb908044ec0c4a92b67e20b1237eb28
7
- data.tar.gz: b10c2447cc7dbead75c291ffd0abdd0a32854906dcb87ffc763d4f0435b31836417eb99f0a7a01649c19b1d3b1572fd56865c2b566ead68c62828964e0d42daa
6
+ metadata.gz: 9123d4ada63311688340fa8da4076a898dfb362764d15030da03cd62776def8172e9be28fc9d7fe47005930c004565860f8997ca46050c7149eb76332c94fa6d
7
+ data.tar.gz: 6dbf48af21a4d6e56a0b21e070a951f8180424de1efbb906830d435200d736b4955781d0f04b3f7677be32cf1ce20a916bdad761faf41c6fd836ac48f06e1aa4
data/CHANGELOG.md CHANGED
@@ -1,5 +1,37 @@
1
1
  # Changelog
2
2
 
3
+ ## [Unreleased]
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
+
11
+ ## [1.11.0] — 2026-10-19
12
+
13
+ ### Fixed
14
+
15
+ - Home listing bands keep their kicker and note on sites built with
16
+ `shell_content_agnostic: true`; the slots are always in the HTML and the
17
+ runtime fills them from `pages/headers.json` (regression from 1.10.0)
18
+
19
+ ### Changed
20
+
21
+ - On the home projects bench at or above 900px, the specimen column takes 35%
22
+ of the band (at least 20rem) and the filing rack 45% (at least 21rem) across
23
+ one `--space-xl` column gap from the band's left edge, keeping the 16:9
24
+ plate at 60% of its previous height. The specimen sets the bench height (the
25
+ tallest project), and the rack rows share that height, each capped at 1.6
26
+ times its minimum
27
+ - Below 900px the project icon-strip glyphs and the plate badge glyph grow to
28
+ 1.375rem
29
+ - The home project specimen title uses `--type-section` from 900px and
30
+ `--type-card-lg` below it; inventory rack titles use `--type-card`
31
+ - The home specimen meta strip wraps instead of clipping on narrow viewports
32
+ - The home hero summary uses `--color-legend`, the same ink as the other
33
+ description texts
34
+
3
35
  ## [1.10.2] — 2026-10-16
4
36
 
5
37
  ### Changed
data/DESIGN.md CHANGED
@@ -941,13 +941,15 @@ Letter-spacing in use, by frequency: `0.1em` (12), `0.08em` (10), `0.12em` (7),
941
941
  icon plate, the name's initial standing in for a missing icon; the title
942
942
  always below the plate; a tagline clamped to the
943
943
  height the plate leaves; tags, outbound links and an emphasis "Open
944
- project" cue). From 900px it takes 50% of the band, beside a 34%
945
- `panelContrast` filing rack of equal-flex project cards (16:9 thumb, code
946
- line, title, tagline; the selected card steps to `surfaceHigh` with a 3px
947
- `primary` inset rule), the rest left blank. Rack click swaps the specimen
944
+ project" cue). From 900px the specimen and a `panelContrast` filing rack
945
+ take 35% and 45% of the band across a `--space-xl` gap, from its left edge;
946
+ rack rows are equal-flex
947
+ cards (square icon mark, code line, title; the selected one steps to
948
+ `surfaceHigh` with a 3px `primary` inset rule) that share the specimen's
949
+ height. Rack click swaps the specimen
948
950
  in place; specimen click opens the project. Below 900px the rack is an
949
951
  icon strip above the specimen, one equal column per project (up to six)
950
- so it spans the band. Contract: `PLT-AC-13`–`17` in
952
+ so it spans the band, with 1.375rem glyphs. Contract: `PLT-AC-13`–`17` in
951
953
  `templates/portfolio/README.md`. Below 768px the hero portrait runs to the screen edge, pulled
952
954
  out of the band's gutter by exactly `--gutter-mobile` on each side and losing
953
955
  its side hairlines, which at the edge would read as a frame cut off.
@@ -1199,14 +1201,12 @@ discs.
1199
1201
  An element renders only if a populated field backs it. The classification axes,
1200
1202
  as enforced by [`_plugins/lib/tag_validator.rb`](_plugins/lib/tag_validator.rb):
1201
1203
 
1202
- * **Articles** classify on `tags`, one to three, from a closed English
1203
- vocabulary: `Aesthetics`, `AI`, `Data`, `DevOps`, `Mathematics`,
1204
- `Robotics`, `Social`. All 22 carry them.
1205
- * **Rascunhos** classify on `tags` too, by subject and in Portuguese, **zero to
1206
- three**: `arte`, `brasilidade`, `carme`, `devaneio`, `engraçadinho`,
1207
- `música`, `pesquisa`, `provocação`, `técnico`, `trabalho`. Unlike articles
1208
- the field may be empty — a notebook holds pages that file nowhere. 29 of 30
1209
- 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.
1210
1210
  * `category` is rejected on both collections — one classification axis, not
1211
1211
  two.
1212
1212
  * **Projects** carry free `tags` (all 8 do), plus `label`, `status`,
@@ -1,6 +1,8 @@
1
1
  {%- comment -%}
2
2
  One homepage listing band. Expects `band` from site.data.pandorga_home_bands.
3
- Markup slots are filled by the home layout script from writing_index.json.
3
+ Kicker and note slots are always emitted, prefilled from headers.yml when it
4
+ was read at build time and filled by the runtime otherwise (PLT-AC-42).
5
+ Listing slots are filled by the home layout script from writing_index.json.
4
6
  {%- endcomment -%}
5
7
  {%- assign _accent = band.accent -%}
6
8
  {%- assign _limit = band.home.limit | default: 3 -%}
@@ -28,13 +30,9 @@
28
30
  {%- if _section.title != blank -%}{%- assign _title = _section.title -%}{%- endif -%}
29
31
  <div class="ledger-section-banner" data-content-home-section="{{ band.section_key }}">
30
32
  <div class="ledger-section-lead">
31
- {%- if _section.kicker != blank -%}
32
33
  <span class="ledger-kicker" data-content-section-kicker>{{ _section.kicker }}</span>
33
- {%- endif -%}
34
34
  <h2 class="section-title" data-content-section-title>{{ _title }}</h2>
35
- {%- if _section.note != blank -%}
36
35
  <p class="home-section-note font-body" data-content-section-note>{{ _section.note }}</p>
37
- {%- endif -%}
38
36
  </div>
39
37
  <div class="home-section-aside">
40
38
  <a class="ledger-cta" href="{{ _path }}">
data/_layouts/home.html CHANGED
@@ -401,21 +401,10 @@ layout: shell
401
401
  if (!bench) return;
402
402
  bench.style.setProperty('--home-project-icon-count', String(iconSlots));
403
403
  var tallest = await measureTallestSpecimen();
404
- /* Desktop matches the specimen height to the N-entry inventory stack
405
- (see --home-inventory-min-height); mobile uses the tallest specimen. */
406
- var inventoryMin = 0;
407
- var raw = window.getComputedStyle(bench)
408
- .getPropertyValue('--home-inventory-min-height').trim();
409
- if (raw) {
410
- var probe = document.createElement('div');
411
- probe.style.cssText = 'position:absolute;visibility:hidden;height:' + raw;
412
- bench.appendChild(probe);
413
- inventoryMin = Math.ceil(probe.getBoundingClientRect().height);
414
- probe.remove();
415
- }
416
- var locked = inventoryMin > 0 ? inventoryMin : tallest;
417
- if (locked > 0) {
418
- bench.style.setProperty('--home-specimen-height', locked + 'px');
404
+ /* Every breakpoint locks the specimen to the tallest project; from
405
+ 900px the rack rows share that height. */
406
+ if (tallest > 0) {
407
+ bench.style.setProperty('--home-specimen-height', tallest + 'px');
419
408
  }
420
409
  }
421
410
 
@@ -308,7 +308,7 @@
308
308
 
309
309
  .home-hero-summary {
310
310
  max-width: var(--reading-measure, 45rem);
311
- color: var(--color-on-surface);
311
+ color: var(--color-legend);
312
312
  line-height: var(--leading-body);
313
313
  }
314
314
 
@@ -1035,10 +1035,11 @@ html[data-theme="light"] .home-hero-plate {
1035
1035
  the inventory. Taglines clamp to two lines with an ellipsis; chips wrap
1036
1036
  whole pills like the projects index; spare height parks chips and the foot
1037
1037
  on the floor. Every plate is 16:9, the ratio the thumbnails are drawn at,
1038
- with the title below it. From 900px the specimen and the rack share equal
1039
- columns (~45.6% each); rows reserve height for three resource icons.
1040
- Below 900px the rack is an icon strip above the specimen, one column
1041
- per project. */
1038
+ with the title below it. From 900px the specimen column takes 35% of the
1039
+ band (at least 20rem) and the rack 45% (at least 21rem) across one
1040
+ `--space-xl` column gap, starting at the band's left edge, and the rack
1041
+ rows share the specimen height. Below 900px the rack is an icon strip
1042
+ above the specimen, one column per project. */
1042
1043
  .home-projects-bench {
1043
1044
  --home-specimen-height: 32rem;
1044
1045
  --home-icon-btn-size: 2.75rem;
@@ -1101,10 +1102,18 @@ html[data-theme="light"] .home-hero-plate {
1101
1102
  letter-spacing: 0.03em;
1102
1103
  }
1103
1104
 
1105
+ /* The specimen strip flows as text, so a long label wraps between words
1106
+ instead of clipping the start year on a phone. */
1104
1107
  .home-specimen-strip .ledger-meta {
1108
+ display: block;
1109
+ white-space: normal;
1105
1110
  font-size: var(--type-label-lg);
1106
1111
  }
1107
1112
 
1113
+ .home-specimen-strip .ledger-meta-sep {
1114
+ margin-inline: var(--space-sm);
1115
+ }
1116
+
1108
1117
  .home-specimen-folio,
1109
1118
  .home-project-inventory-index {
1110
1119
  color: var(--color-primary);
@@ -1205,6 +1214,22 @@ html[data-theme="light"] .home-hero-plate {
1205
1214
  color: var(--color-primary);
1206
1215
  }
1207
1216
 
1217
+ @media (max-width: 899px) {
1218
+ .home-specimen-figure:has(> .ledger-poster)>.home-specimen-badge {
1219
+ font-size: 1.375rem;
1220
+ }
1221
+
1222
+ /* Two classes: the Material Symbols sheet loads after this one and resets
1223
+ the glyph size on a single class. */
1224
+ .home-project-inventory-row .home-project-inventory-glyph {
1225
+ font-size: 1.375rem;
1226
+ }
1227
+
1228
+ .home-project-inventory-row .home-project-inventory-glyph--initial {
1229
+ font-size: var(--type-card);
1230
+ }
1231
+ }
1232
+
1208
1233
  .home-project-specimen .home-specimen-title {
1209
1234
  margin: 0;
1210
1235
  font-size: var(--type-card-lg);
@@ -1363,23 +1388,22 @@ button.home-project-inventory-row {
1363
1388
 
1364
1389
  @media (min-width: 900px) {
1365
1390
 
1366
- /* Specimen and inventory share equal columns (~45.6% each). Each row fits
1367
- its square icon mark and two-line code + title body; the specimen height
1368
- matches the N-entry inventory stack. */
1391
+ /* The specimen column is 35% of the band (at least 20rem), which keeps the
1392
+ 16:9 plate at 60% of the 7:5 split's height; the rack is 45% (at least
1393
+ 21rem); the pair starts at the band's left edge and leaves the rest of
1394
+ the row blank. The specimen sets the bench height; rack rows share it. */
1369
1395
  .home-projects-bench {
1370
- --home-inventory-mark-size: 2.75rem;
1396
+ --home-inventory-mark-size: 3rem;
1371
1397
  --home-inventory-row-min: calc(
1372
1398
  var(--home-inventory-mark-size) + 2 * var(--space-sm) + 2px
1373
1399
  );
1374
- --home-inventory-min-height: calc(
1375
- var(--home-project-icon-count, 6) * var(--home-inventory-row-min) +
1376
- (var(--home-project-icon-count, 6) - 1) * var(--space-sm) +
1377
- 2 * var(--space-md)
1378
- );
1379
- --home-specimen-height: var(--home-inventory-min-height);
1400
+ /* Rows share the specimen height, but a short rack must not stretch a
1401
+ single row to the full plate. */
1402
+ --home-inventory-row-max: calc(1.6 * var(--home-inventory-row-min));
1380
1403
  display: grid;
1381
- grid-template-columns: minmax(0, 45.6%) minmax(0, 45.6%);
1404
+ grid-template-columns: max(35%, 20rem) max(45%, 21rem);
1382
1405
  column-gap: var(--space-xl);
1406
+ justify-content: start;
1383
1407
  align-items: stretch;
1384
1408
  }
1385
1409
 
@@ -1390,6 +1414,7 @@ button.home-project-inventory-row {
1390
1414
  gap: var(--space-sm);
1391
1415
  box-sizing: border-box;
1392
1416
  width: 100%;
1417
+ min-height: var(--home-specimen-height);
1393
1418
  min-width: 0;
1394
1419
  min-height: 0;
1395
1420
  overflow: hidden;
@@ -1397,38 +1422,18 @@ button.home-project-inventory-row {
1397
1422
  background: var(--color-panel-contrast);
1398
1423
  }
1399
1424
 
1400
- .home-specimen-figure:has(> .ledger-poster, > .projects-plate) {
1401
- flex: 1 1 0;
1402
- min-height: 0;
1403
- grid-template-rows: minmax(0, 1fr) auto;
1404
- gap: var(--space-sm);
1405
- }
1406
-
1407
- .home-specimen-figure > .ledger-poster,
1408
- .home-specimen-figure > .projects-plate {
1409
- height: 100%;
1410
- min-height: 0;
1411
- aspect-ratio: auto;
1412
- }
1413
-
1414
1425
  .home-project-specimen .home-specimen-title {
1415
- font-size: var(--type-title);
1416
- }
1417
-
1418
- .home-project-specimen .ledger-card-copy {
1419
- flex: 0 0 auto;
1420
- height: auto;
1421
- min-height: 0;
1422
- max-height: calc(var(--type-body) * var(--leading-body) * 2);
1426
+ font-size: var(--type-section);
1423
1427
  }
1424
1428
 
1425
1429
  .home-project-inventory-row {
1426
- flex: 0 0 auto;
1430
+ flex: 1 1 0;
1427
1431
  align-items: center;
1428
1432
  justify-content: flex-start;
1429
1433
  gap: var(--space-md);
1430
- height: var(--home-inventory-row-min);
1434
+ height: auto;
1431
1435
  min-height: var(--home-inventory-row-min);
1436
+ max-height: var(--home-inventory-row-max);
1432
1437
  box-sizing: border-box;
1433
1438
  padding: var(--space-sm) var(--space-md);
1434
1439
  border: 1px solid var(--border-gentle-divider);
@@ -1506,7 +1511,7 @@ button.home-project-inventory-row {
1506
1511
  .home-project-inventory-title {
1507
1512
  margin: 0;
1508
1513
  font-family: var(--font-content-title);
1509
- font-size: calc(var(--type-card) - 2pt);
1514
+ font-size: var(--type-card);
1510
1515
  font-weight: var(--weight-title);
1511
1516
  line-height: 1.2;
1512
1517
  color: var(--color-on-surface);
@@ -1862,7 +1867,7 @@ button.home-project-inventory-row {
1862
1867
  margin-top: 0;
1863
1868
  font-size: var(--type-body);
1864
1869
  line-height: var(--leading-body);
1865
- color: var(--color-on-surface);
1870
+ color: var(--color-legend);
1866
1871
  }
1867
1872
 
1868
1873
  .home-hero-summary p {
@@ -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.10.2"
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.10.2"
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
@@ -227,8 +227,11 @@ sheets.each do |path|
227
227
  end
228
228
 
229
229
  # PLT-AC-41. Home project inventory rows use the square icon mark, two-line
230
- # code + title body, and no tagline preview or attached-resource icons; the
231
- # desktop specimen height matches the N-entry inventory height.
230
+ # code + title body, and no tagline preview or attached-resource icons; on
231
+ # desktop the specimen (35%, min 20rem) and rack (45%, min 21rem) start at the
232
+ # band's left edge across one --space-xl gap, the specimen sets the bench
233
+ # height, rack titles use --type-card, the specimen strip wraps instead of
234
+ # clipping, and below 900px the rack tile glyphs are 1.375rem.
232
235
  home_sheet = strip_comments(File.read(CSS_ROOT.join("ledger/home.css")))
233
236
  %w[
234
237
  home-project-inventory-copy
@@ -245,8 +248,40 @@ require_declarations(
245
248
  )
246
249
  require_declarations(
247
250
  errors, "assets/css/ledger/home.css", home_sheet,
248
- ".home-projects-bench", ["--home-specimen-height: var(--home-inventory-min-height)"]
251
+ ".home-projects-bench", ["grid-template-columns: max(35%, 20rem) max(45%, 21rem)", "column-gap: var(--space-xl)", "justify-content: start", "--home-inventory-row-max: calc(1.6 * var(--home-inventory-row-min))"]
249
252
  )
253
+ require_declarations(
254
+ errors, "assets/css/ledger/home.css", home_sheet,
255
+ ".home-specimen-strip .ledger-meta", ["display: block", "white-space: normal"]
256
+ )
257
+ require_declarations(
258
+ errors, "assets/css/ledger/home.css", home_sheet,
259
+ ".home-project-inventory-title", ["font-size: var(--type-card)"]
260
+ )
261
+ require_declarations(
262
+ errors, "assets/css/ledger/home.css", home_sheet,
263
+ ".home-project-inventory-row .home-project-inventory-glyph", ["font-size: 1.375rem"]
264
+ )
265
+ [
266
+ "45.6%",
267
+ "minmax(0, 7fr)",
268
+ "--home-inventory-min-height",
269
+ "calc(var(--type-card) - 2pt)"
270
+ ].each do |forbidden_home|
271
+ if home_sheet.include?(forbidden_home)
272
+ errors << "assets/css/ledger/home.css: #{forbidden_home} must be removed (PLT-AC-41)"
273
+ end
274
+ end
275
+
276
+ # PLT-AC-23. The home hero summary uses --color-legend, the ink of the other
277
+ # description texts.
278
+ require_declarations(
279
+ errors, "assets/css/ledger/home.css", home_sheet,
280
+ ".home-hero-summary", ["color: var(--color-legend)"]
281
+ )
282
+ if declaration_bodies(home_sheet, ".home-hero-summary").any? { |body| declarations(body).include?("color:var(--color-on-surface)") }
283
+ errors << "assets/css/ledger/home.css: .home-hero-summary must use --color-legend (PLT-AC-23)"
284
+ end
250
285
 
251
286
  fail!("CSS structure problems:\n - #{errors.join("\n - ")}") unless errors.empty?
252
287
 
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Refs: PLT-AC-42 — home listing bands emit kicker, title, and note slots in both normal and agnostic builds.
4
+ require "pathname"
5
+ require "open3"
6
+ require "fileutils"
7
+ require "tmpdir"
8
+
9
+ ROOT = Pathname.new(__dir__).join("../..").expand_path
10
+ EXE = ROOT.join("exe/pandorga")
11
+ EXAMPLE = ROOT.join("examples/full")
12
+
13
+ def fail!(message)
14
+ warn "FAIL test-home-band-intro: #{message}"
15
+ exit 1
16
+ end
17
+
18
+ def run!(*cmd, chdir:)
19
+ env = {
20
+ "RUBYOPT" => "-I#{ROOT.join('lib')}",
21
+ "PANDORGA_SITE_ROOT" => chdir.to_s,
22
+ "CONTENT_API_BASE_URL" => "",
23
+ "CONTENT_EXPORT_SKIP_VALIDATE" => "1",
24
+ "JEKYLL_ENV" => "production"
25
+ }
26
+ stdout, stderr, status = Open3.capture3(env, *cmd, chdir: chdir.to_s)
27
+ return [stdout, stderr] if status.success?
28
+
29
+ fail!("#{cmd.join(' ')} (cwd=#{chdir})\n#{stdout}\n#{stderr}")
30
+ end
31
+
32
+ def listing_sections(html)
33
+ sections = []
34
+ html.scan(/<section\b[^>]*\bdata-home-kind="([^"]+)"[^>]*>.*?<\/section>/m) do
35
+ section = Regexp.last_match(0)
36
+ kind = Regexp.last_match(1)
37
+ next if kind == "index"
38
+
39
+ key = section[/\bdata-home-key="([^"]+)"/, 1] || kind
40
+ banner_idx = section.index('data-content-home-section="')
41
+ fail!("section #{key} missing data-content-home-section") unless banner_idx
42
+
43
+ sections << [key, section[banner_idx..]]
44
+ end
45
+ sections
46
+ end
47
+
48
+ run!("bundle", "install", "--quiet", chdir: EXAMPLE) if EXAMPLE.join("Gemfile").file?
49
+ run!("ruby", EXE.to_s, "export", "content", "_content_json", chdir: EXAMPLE)
50
+
51
+ Dir.mktmpdir("pandorga-home-band-normal-") do |normal_dir|
52
+ run!("bundle", "exec", "jekyll", "build", "--quiet", "-d", normal_dir, chdir: EXAMPLE)
53
+ html = File.read(File.join(normal_dir, "index.html"))
54
+ sections = listing_sections(html)
55
+ fail!("normal build produced no listing sections") if sections.empty?
56
+
57
+ sections.each do |key, slice|
58
+ %w[data-content-section-kicker data-content-section-title data-content-section-note].each do |attr|
59
+ fail!("normal build band #{key} missing #{attr}") unless slice.include?(attr)
60
+ end
61
+ end
62
+
63
+ projects = sections.assoc("projects")&.last
64
+ fail!("normal build missing projects band") unless projects
65
+ unless projects.include?("Portfolio systems in the full example.")
66
+ fail!("normal build projects band missing note text from headers.yml")
67
+ end
68
+ end
69
+
70
+ Dir.mktmpdir("pandorga-home-band-agnostic-") do |agnostic_dir|
71
+ agnostic_cfg = File.join(agnostic_dir, "agnostic.yml")
72
+ File.write(agnostic_cfg, "shell_content_agnostic: true\n")
73
+ out_dir = File.join(agnostic_dir, "site")
74
+ run!(
75
+ "bundle", "exec", "jekyll", "build", "--quiet",
76
+ "--config", "_config.yml,#{agnostic_cfg}",
77
+ "-d", out_dir,
78
+ chdir: EXAMPLE
79
+ )
80
+ html = File.read(File.join(out_dir, "index.html"))
81
+ sections = listing_sections(html)
82
+ fail!("agnostic build produced no listing sections") if sections.empty?
83
+
84
+ sections.each do |key, slice|
85
+ %w[data-content-section-kicker data-content-section-title data-content-section-note].each do |attr|
86
+ fail!("agnostic build band #{key} missing #{attr}") unless slice.include?(attr)
87
+ end
88
+ end
89
+
90
+ projects = sections.assoc("projects")&.last
91
+ fail!("agnostic build missing projects band") unless projects
92
+ unless projects.include?("data-content-section-note></p>")
93
+ fail!("agnostic build projects band note slot is not empty")
94
+ end
95
+ end
96
+
97
+ puts "PASS test-home-band-intro"
@@ -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
@@ -55,6 +56,7 @@ TESTS=(
55
56
  scripts/test/test-r2-json-sync.rb
56
57
  scripts/test/test-writing-slug-limits.rb
57
58
  scripts/test/test-examples-build.rb
59
+ scripts/test/test-home-band-intro.rb
58
60
  scripts/test/test-validate-allowlist.rb
59
61
  scripts/test/test-shell-a11y.rb
60
62
  scripts/test/test-theme-contrast.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.10.2
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
@@ -435,6 +437,7 @@ files:
435
437
  - scripts/test/test-hero-summary.rb
436
438
  - scripts/test/test-home-articles.rb
437
439
  - scripts/test/test-home-band-art.rb
440
+ - scripts/test/test-home-band-intro.rb
438
441
  - scripts/test/test-home-lists.rb
439
442
  - scripts/test/test-hydrate-reserve.rb
440
443
  - scripts/test/test-install-functions.rb
@@ -466,6 +469,7 @@ files:
466
469
  - scripts/test/test-studio-rewrite-sanitize.mjs
467
470
  - scripts/test/test-studio-schema-composition.rb
468
471
  - scripts/test/test-studio-slug-config.rb
472
+ - scripts/test/test-studio-taxonomy.rb
469
473
  - scripts/test/test-template-packages.rb
470
474
  - scripts/test/test-text-excerpt.rb
471
475
  - scripts/test/test-theme-contrast.rb