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 +4 -4
- data/CHANGELOG.md +32 -0
- data/DESIGN.md +13 -13
- data/_includes/home/band-listing.html +3 -5
- data/_layouts/home.html +4 -15
- data/assets/css/ledger/home.css +47 -42
- 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-css-structure.rb +38 -3
- data/scripts/test/test-home-band-intro.rb +97 -0
- data/scripts/test/test-studio-taxonomy.rb +246 -0
- data/scripts/validate.sh +2 -0
- data/studio/schema.yml +4 -17
- metadata +5 -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
|
@@ -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
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
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
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
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
|
-
|
|
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
|
-
/*
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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
|
|
data/assets/css/ledger/home.css
CHANGED
|
@@ -308,7 +308,7 @@
|
|
|
308
308
|
|
|
309
309
|
.home-hero-summary {
|
|
310
310
|
max-width: var(--reading-measure, 45rem);
|
|
311
|
-
color: var(--color-
|
|
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
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
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
|
-
/*
|
|
1367
|
-
|
|
1368
|
-
|
|
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:
|
|
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
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
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:
|
|
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-
|
|
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:
|
|
1430
|
+
flex: 1 1 0;
|
|
1427
1431
|
align-items: center;
|
|
1428
1432
|
justify-content: flex-start;
|
|
1429
1433
|
gap: var(--space-md);
|
|
1430
|
-
height:
|
|
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:
|
|
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-
|
|
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.
|
|
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
|
@@ -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;
|
|
231
|
-
# desktop specimen
|
|
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-
|
|
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
|
|
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
|
|
@@ -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
|