pandorga 2.0.0 → 2.0.2
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 +17 -0
- data/_includes/content-runtime/43-blocks.html +5 -1
- data/_includes/content-runtime/60-fragments.html +7 -0
- data/_includes/home/band-index.html +22 -0
- data/_layouts/home.html +1 -0
- data/assets/css/ledger/home.css +10 -12
- data/lib/pandorga/commands/new.rb +1 -1
- data/lib/pandorga/hero_summary.rb +19 -4
- data/lib/pandorga/jekyll/plugins/hero_summary.rb +4 -1
- data/lib/pandorga/version.rb +1 -1
- data/scripts/content/publish-content-to-object-store.sh +54 -19
- data/scripts/content/sync-content-json-to-r2.rb +1 -1
- data/scripts/test/test-hero-summary.rb +41 -0
- data/scripts/test/test-home-article-cards.rb +56 -0
- data/scripts/test/test-parallel-content-publish.rb +171 -0
- data/scripts/validate.sh +2 -0
- data/studio/schema.yml +11 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 138bcb8f0c2e5c6eec6316000865742ac1ebb5c57703beb8db532bfdca64a270
|
|
4
|
+
data.tar.gz: 9f7fc0e7385d8b542cd6507fe00c0e1110012d4ec7688e277978ecfb611d9034
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 46f7672a6254b39d874c34ba00555a287a94feabbba29d59582cdd2b8436fa874b6c7a3c0ff5070c9bebd83f5cd71144458ed19068c6d553001d8c6d5d9f6e5d
|
|
7
|
+
data.tar.gz: 95017e5b2b5d9152a41d705fd652546273e812f9f4efa7048792f34b08cf0272478d06680e3e707360425ef22a5329cb758863bcf2250f2a67dfdc31f6f50dee
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [2.0.2] — 2026-10-25
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- The home hero summary has its own file, `content/pages/home/summary.md`, editable in Studio as "Hero summary" under Presentation, so the site presentation and the CV page summary can differ. Without the file the hero keeps showing `content/pages/cv/summary.md`; saved empty, the hero shows no summary. The Studio CV summary entry for the public page is now labelled "CV page" (PLT-AC-54).
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- The object-store publish syncs `collections/`, `data/`, `pages/` and `media/` as parallel jobs, prints each job's log in a fixed order, and uploads `manifest.json` last, only after every job succeeded. Upload, delete and verification semantics are unchanged (PLT-AC-53).
|
|
14
|
+
|
|
15
|
+
## [2.0.1] — 2026-10-24
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- Home article cards set their tags and "Read" on one row, like the featured card, and every card's top rule uses the Articles accent instead of rotating through the palette, which drew the second card in the secondary hue (PLT-AC-52).
|
|
20
|
+
- A home band header whose kicker, title and note are all saved empty is removed with its rule instead of leaving an empty ruled strip; the band keeps an `aria-label` (PLT-AC-52).
|
|
21
|
+
|
|
5
22
|
## [2.0.0] — 2026-10-24
|
|
6
23
|
|
|
7
24
|
### Added
|
|
@@ -275,10 +275,14 @@
|
|
|
275
275
|
html += '<p class="ledger-card-copy font-body">' +
|
|
276
276
|
escapeHtml(f.preview) + '</p>';
|
|
277
277
|
}
|
|
278
|
-
|
|
278
|
+
var chips = renderTagChips(f.tags, { hrefFor: options.tagHref, limit: options.tagLimit });
|
|
279
|
+
// `tagsInFoot` sets the chips on the call to action's row, as the home
|
|
280
|
+
// featured card does (PLT-AC-52); otherwise they sit above the foot.
|
|
281
|
+
if (!options.tagsInFoot) html += chips;
|
|
279
282
|
// The meta strip above already carries the date, so the footer copy is
|
|
280
283
|
// opt-in rather than the default — printing it twice is the common bug.
|
|
281
284
|
var foot = '';
|
|
285
|
+
if (options.tagsInFoot) foot += chips;
|
|
282
286
|
if (options.showFooterDates) {
|
|
283
287
|
foot += renderCardFooterDates(f.date, f.lastUpdated, locale);
|
|
284
288
|
}
|
|
@@ -431,6 +431,13 @@
|
|
|
431
431
|
['green', 'ochre'].forEach(function (name) {
|
|
432
432
|
band.classList.toggle('page-accent--' + name, accent === name);
|
|
433
433
|
});
|
|
434
|
+
/* A header left with no text and no link goes with its rule, and the
|
|
435
|
+
* band keeps a name from its data-home-label (PLT-AC-52). */
|
|
436
|
+
if (!root.querySelector('[data-content-section-kicker], [data-content-section-title], [data-content-section-note], .home-section-aside')) {
|
|
437
|
+
var label = band.getAttribute('data-home-label');
|
|
438
|
+
if (label && !band.hasAttribute('aria-label')) band.setAttribute('aria-label', label);
|
|
439
|
+
root.remove();
|
|
440
|
+
}
|
|
434
441
|
}
|
|
435
442
|
|
|
436
443
|
function yieldToMain() {
|
|
@@ -2,14 +2,35 @@
|
|
|
2
2
|
Grouped index band (sources / network / bibliography). Expects `band` with
|
|
3
3
|
kind=index and a pages array from PandorgaRegistry.home_bands.
|
|
4
4
|
{%- endcomment -%}
|
|
5
|
+
{%- comment -%}
|
|
6
|
+
The band's name when its header is gone: the panels' default titles. A
|
|
7
|
+
header whose kicker, title and note are all blank in headers.yml is not
|
|
8
|
+
emitted at all, so no empty ruled strip is left (PLT-AC-52).
|
|
9
|
+
{%- endcomment -%}
|
|
10
|
+
{%- assign _band_label = "" -%}
|
|
11
|
+
{%- for panel in band.pages -%}
|
|
12
|
+
{%- assign _label_page = site.pages | where: "key", panel.key | first -%}
|
|
13
|
+
{%- assign _label = panel.title | default: _label_page.title | default: panel.key -%}
|
|
14
|
+
{%- if _band_label != "" -%}{%- assign _band_label = _band_label | append: ", " -%}{%- endif -%}
|
|
15
|
+
{%- assign _band_label = _band_label | append: _label -%}
|
|
16
|
+
{%- endfor -%}
|
|
17
|
+
{%- assign _hide_banner = false -%}
|
|
18
|
+
{%- if site.data.page_headers -%}
|
|
19
|
+
{%- assign _band_head = site.data.page_headers[band.section_key] -%}
|
|
20
|
+
{%- assign _band_text = _band_head.kicker | append: _band_head.title | append: _band_head.note | strip -%}
|
|
21
|
+
{%- if _band_text == "" -%}{%- assign _hide_banner = true -%}{%- endif -%}
|
|
22
|
+
{%- endif -%}
|
|
5
23
|
<section
|
|
6
24
|
id="home-{{ band.group }}"
|
|
7
25
|
class="home-band home-band--contrast home-band--index"
|
|
8
26
|
data-home-band
|
|
9
27
|
data-home-kind="index"
|
|
10
28
|
data-home-group="{{ band.group }}"
|
|
29
|
+
data-home-label="{{ _band_label | escape }}"
|
|
30
|
+
{%- if _hide_banner %} aria-label="{{ _band_label | escape }}"{% endif %}
|
|
11
31
|
>
|
|
12
32
|
<div class="home-band-inner">
|
|
33
|
+
{%- unless _hide_banner -%}
|
|
13
34
|
<div class="ledger-section-banner" data-content-home-section="{{ band.section_key }}">
|
|
14
35
|
<div class="ledger-section-lead">
|
|
15
36
|
<span class="ledger-kicker" data-content-section-kicker></span>
|
|
@@ -17,6 +38,7 @@
|
|
|
17
38
|
<p class="home-section-note font-body" data-content-section-note></p>
|
|
18
39
|
</div>
|
|
19
40
|
</div>
|
|
41
|
+
{%- endunless -%}
|
|
20
42
|
<div class="home-index-split">
|
|
21
43
|
{%- for panel in band.pages -%}
|
|
22
44
|
{%- assign _panel_page = site.pages | where: "key", panel.key | first -%}
|
data/_layouts/home.html
CHANGED
data/assets/css/ledger/home.css
CHANGED
|
@@ -222,7 +222,8 @@
|
|
|
222
222
|
|
|
223
223
|
/* No empty min-height. A guessed line count shifts the call to action
|
|
224
224
|
when the summary is shorter or taller than the reserve. The paragraph
|
|
225
|
-
is in the HTML when content/pages/
|
|
225
|
+
is in the HTML when content/pages/home/summary.md or
|
|
226
|
+
content/pages/cv/summary.md exists. */
|
|
226
227
|
|
|
227
228
|
.home-hero-summary p {
|
|
228
229
|
margin: 0 0 var(--space-md);
|
|
@@ -763,26 +764,23 @@ html[data-theme="light"] .home-hero-plate {
|
|
|
763
764
|
display: none;
|
|
764
765
|
}
|
|
765
766
|
|
|
767
|
+
/* Tags and "Read" share the last row, as on the featured card (PLT-AC-52). */
|
|
766
768
|
.home-card-grid .ledger-card-foot {
|
|
767
|
-
justify-content:
|
|
769
|
+
justify-content: space-between;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
.home-card-grid .ledger-card-foot .ledger-cta {
|
|
773
|
+
margin-left: auto;
|
|
768
774
|
}
|
|
769
775
|
|
|
770
|
-
/*
|
|
771
|
-
|
|
776
|
+
/* Every card's top rule is the band accent, which `page-accent--<accent>`
|
|
777
|
+
sets on the section (PLT-AC-52). */
|
|
772
778
|
.home-card-grid .ledger-card {
|
|
773
779
|
border-top-width: 2px;
|
|
774
780
|
border-top-style: solid;
|
|
775
781
|
border-top-color: var(--color-primary);
|
|
776
782
|
}
|
|
777
783
|
|
|
778
|
-
.home-card-grid .ledger-card:nth-child(2) {
|
|
779
|
-
border-top-color: var(--color-secondary);
|
|
780
|
-
}
|
|
781
|
-
|
|
782
|
-
.home-card-grid .ledger-card:nth-child(3) {
|
|
783
|
-
border-top-color: var(--color-tertiary);
|
|
784
|
-
}
|
|
785
|
-
|
|
786
784
|
/* ── Rascunhos: dropped sheets ──
|
|
787
785
|
Four compact cards rather than four full-width text rows: the poster is
|
|
788
786
|
visible at every breakpoint now (it used to hide below 640px), each card
|
|
@@ -24,7 +24,7 @@ module Pandorga
|
|
|
24
24
|
|
|
25
25
|
gem "pandorga", "~> 2.0"
|
|
26
26
|
# Local checkout: gem "pandorga", path: "../pandorga"
|
|
27
|
-
# GitHub tag: gem "pandorga", github: "alexandrelheinen/pandorga", tag: "v2.0.
|
|
27
|
+
# GitHub tag: gem "pandorga", github: "alexandrelheinen/pandorga", tag: "v2.0.2"
|
|
28
28
|
gem "csv"
|
|
29
29
|
gem "base64"
|
|
30
30
|
RUBY
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# Home hero summary from content/pages/
|
|
3
|
+
# Home hero summary from content/pages/home/summary.md, falling back to
|
|
4
|
+
# content/pages/cv/summary.md when the hero file is absent (PLT-AC-54).
|
|
4
5
|
# That paragraph is the LCP element. Leaving it empty until JSON arrives
|
|
5
6
|
# makes most of LCP render delay, and a guessed empty box shifts the
|
|
6
7
|
# call to action when the real text replaces it.
|
|
7
8
|
# Refs: PLT-AC-23
|
|
8
9
|
module Pandorga
|
|
9
10
|
module HeroSummary
|
|
11
|
+
HOME_RELATIVE = "content/pages/home/summary.md"
|
|
10
12
|
RELATIVE = "content/pages/cv/summary.md"
|
|
11
13
|
FALLBACK = '<div id="home-cv-summary" class="home-hero-summary font-body" ' \
|
|
12
14
|
'data-content-fragment="cv/summary" data-content-quiet></div>'
|
|
@@ -14,11 +16,24 @@ module Pandorga
|
|
|
14
16
|
module_function
|
|
15
17
|
|
|
16
18
|
def markdown(source_root)
|
|
19
|
+
home = read_body(source_root, HOME_RELATIVE)
|
|
20
|
+
return home unless home.nil?
|
|
21
|
+
|
|
22
|
+
read_body(source_root, RELATIVE).to_s
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# A hero file saved with a blank body hides the hero summary.
|
|
26
|
+
def hidden?(source_root)
|
|
27
|
+
read_body(source_root, HOME_RELATIVE) == ""
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The stripped body of a content file, or nil when it is absent.
|
|
31
|
+
def read_body(source_root, relative)
|
|
17
32
|
root = File.expand_path(source_root.to_s)
|
|
18
|
-
path = File.expand_path(
|
|
33
|
+
path = File.expand_path(relative, root)
|
|
19
34
|
prefix = root.end_with?(File::SEPARATOR) ? root : root + File::SEPARATOR
|
|
20
|
-
return
|
|
21
|
-
return
|
|
35
|
+
return nil unless path.start_with?(prefix)
|
|
36
|
+
return nil unless File.file?(path)
|
|
22
37
|
|
|
23
38
|
strip_front_matter(File.read(path, encoding: "UTF-8")).strip
|
|
24
39
|
end
|
|
@@ -5,10 +5,13 @@ require_relative "../../hero_summary"
|
|
|
5
5
|
|
|
6
6
|
module Pandorga
|
|
7
7
|
# Emits the hero summary element. HTML is raw so the paragraph is in the
|
|
8
|
-
# first response when the markdown file exists.
|
|
8
|
+
# first response when the markdown file exists. A blank hero file
|
|
9
|
+
# renders nothing (PLT-AC-54).
|
|
9
10
|
class HeroSummaryTag < Liquid::Tag
|
|
10
11
|
def render(context)
|
|
11
12
|
site = context.registers[:site]
|
|
13
|
+
return "" if HeroSummary.hidden?(site.source)
|
|
14
|
+
|
|
12
15
|
HeroSummary.element(HeroSummary.html_for(site))
|
|
13
16
|
end
|
|
14
17
|
end
|
data/lib/pandorga/version.rb
CHANGED
|
@@ -58,36 +58,71 @@ run_ruby() {
|
|
|
58
58
|
|
|
59
59
|
sync_json_prefix() {
|
|
60
60
|
local prefix="$1"
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
echo "==> Object store replace sync $prefix/"
|
|
64
|
-
run_ruby "$ROOT/scripts/content/sync-content-json-to-r2.rb" "$EXPORT_ROOT" "$prefix"
|
|
65
|
-
fi
|
|
61
|
+
echo "==> Object store replace sync $prefix/"
|
|
62
|
+
run_ruby "$ROOT/scripts/content/sync-content-json-to-r2.rb" "$EXPORT_ROOT" "$prefix"
|
|
66
63
|
}
|
|
67
64
|
|
|
68
|
-
echo "==> Publishing content JSON to bucket: ${S3_BUCKET}"
|
|
69
|
-
echo "==> Endpoint: ${S3_ENDPOINT}"
|
|
70
|
-
echo "==> Site root: ${SITE_ROOT}"
|
|
71
|
-
sync_json_prefix collections
|
|
72
|
-
sync_json_prefix data
|
|
73
|
-
sync_json_prefix pages
|
|
74
|
-
|
|
75
|
-
echo "==> Upload manifest.json"
|
|
76
|
-
aws s3 cp "$EXPORT_ROOT/manifest.json" "s3://${S3_BUCKET}/manifest.json" \
|
|
77
|
-
--endpoint-url "$S3_ENDPOINT" \
|
|
78
|
-
--cache-control "public,max-age=60" \
|
|
79
|
-
--content-type "application/json"
|
|
80
|
-
|
|
81
65
|
MEDIA_ROOT="$SITE_ROOT/content/media"
|
|
82
|
-
|
|
66
|
+
|
|
67
|
+
sync_media() {
|
|
83
68
|
echo "==> Sync media/ from ${MEDIA_ROOT}"
|
|
84
69
|
aws s3 sync "$MEDIA_ROOT" "s3://${S3_BUCKET}/media" \
|
|
85
70
|
--endpoint-url "$S3_ENDPOINT" \
|
|
86
71
|
--delete \
|
|
87
72
|
--size-only \
|
|
88
73
|
--cache-control "public,max-age=31536000,immutable"
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
echo "==> Publishing content JSON to bucket: ${S3_BUCKET}"
|
|
77
|
+
echo "==> Endpoint: ${S3_ENDPOINT}"
|
|
78
|
+
echo "==> Site root: ${SITE_ROOT}"
|
|
79
|
+
|
|
80
|
+
# The prefixes are disjoint, so their syncs run as parallel jobs (PLT-AC-53).
|
|
81
|
+
# Each job logs to its own file; the logs print in a fixed order afterwards.
|
|
82
|
+
JOB_LOGS="$(mktemp -d)"
|
|
83
|
+
trap 'rm -rf "$JOB_LOGS"' EXIT
|
|
84
|
+
JOB_NAMES=()
|
|
85
|
+
JOB_PIDS=()
|
|
86
|
+
|
|
87
|
+
start_job() {
|
|
88
|
+
local name="$1"
|
|
89
|
+
shift
|
|
90
|
+
( "$@" ) > "$JOB_LOGS/$name.log" 2>&1 &
|
|
91
|
+
JOB_NAMES+=("$name")
|
|
92
|
+
JOB_PIDS+=("$!")
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
for prefix in collections data pages; do
|
|
96
|
+
if [ -d "$EXPORT_ROOT/$prefix" ]; then
|
|
97
|
+
start_job "$prefix" sync_json_prefix "$prefix"
|
|
98
|
+
fi
|
|
99
|
+
done
|
|
100
|
+
|
|
101
|
+
if [ -d "$MEDIA_ROOT" ]; then
|
|
102
|
+
start_job media sync_media
|
|
89
103
|
else
|
|
90
104
|
echo "==> No content/media at ${MEDIA_ROOT} (skipping media sync)"
|
|
91
105
|
fi
|
|
92
106
|
|
|
107
|
+
FAILED=()
|
|
108
|
+
for i in "${!JOB_PIDS[@]}"; do
|
|
109
|
+
if ! wait "${JOB_PIDS[$i]}"; then
|
|
110
|
+
FAILED+=("${JOB_NAMES[$i]}")
|
|
111
|
+
fi
|
|
112
|
+
cat "$JOB_LOGS/${JOB_NAMES[$i]}.log"
|
|
113
|
+
done
|
|
114
|
+
|
|
115
|
+
if [ "${#FAILED[@]}" -gt 0 ]; then
|
|
116
|
+
echo "error: object-store sync failed for: ${FAILED[*]}; manifest.json not uploaded." >&2
|
|
117
|
+
exit 1
|
|
118
|
+
fi
|
|
119
|
+
|
|
120
|
+
# Readers start from the manifest, so it goes up only after every object it
|
|
121
|
+
# can lead to is in place.
|
|
122
|
+
echo "==> Upload manifest.json"
|
|
123
|
+
aws s3 cp "$EXPORT_ROOT/manifest.json" "s3://${S3_BUCKET}/manifest.json" \
|
|
124
|
+
--endpoint-url "$S3_ENDPOINT" \
|
|
125
|
+
--cache-control "public,max-age=60" \
|
|
126
|
+
--content-type "application/json"
|
|
127
|
+
|
|
93
128
|
echo "==> Object-store publish complete"
|
|
@@ -39,7 +39,7 @@ endpoint = ENV["R2_ENDPOINT"].to_s
|
|
|
39
39
|
bucket = ENV["R2_BUCKET"].to_s
|
|
40
40
|
fail!("R2_ENDPOINT is required") if endpoint.empty?
|
|
41
41
|
fail!("R2_BUCKET is required") if bucket.empty?
|
|
42
|
-
fail!("aws CLI is required") if Open3.capture3("bash", "-
|
|
42
|
+
fail!("aws CLI is required") if Open3.capture3("bash", "-c", "command -v aws").last.exitstatus != 0
|
|
43
43
|
|
|
44
44
|
SRC = EXPORT_ROOT.join(PREFIX)
|
|
45
45
|
fail!("Missing local prefix directory: #{SRC}") unless SRC.directory?
|
|
@@ -33,6 +33,47 @@ Dir.mktmpdir("hero-summary") do |dir|
|
|
|
33
33
|
fail!("front matter leaked: #{mattered.inspect}") unless mattered == "After the matter."
|
|
34
34
|
end
|
|
35
35
|
|
|
36
|
+
# PLT-AC-54: the hero has its own file; absent falls back, blank hides.
|
|
37
|
+
Dir.mktmpdir("hero-summary-field") do |dir|
|
|
38
|
+
cv = File.join(dir, "content/pages/cv")
|
|
39
|
+
home = File.join(dir, "content/pages/home")
|
|
40
|
+
FileUtils.mkdir_p([cv, home])
|
|
41
|
+
File.write(File.join(cv, "summary.md"), "Professional summary.\n")
|
|
42
|
+
|
|
43
|
+
fail!("absent hero file must fall back to the CV summary") unless Pandorga::HeroSummary.markdown(dir) == "Professional summary."
|
|
44
|
+
fail!("absent hero file must not hide the hero summary") if Pandorga::HeroSummary.hidden?(dir)
|
|
45
|
+
|
|
46
|
+
File.write(File.join(home, "summary.md"), "What the site holds.\n")
|
|
47
|
+
own = Pandorga::HeroSummary.markdown(dir)
|
|
48
|
+
fail!("hero file must win over the CV summary, got #{own.inspect}") unless own == "What the site holds."
|
|
49
|
+
fail!("a hero file with text must not hide") if Pandorga::HeroSummary.hidden?(dir)
|
|
50
|
+
|
|
51
|
+
File.write(File.join(home, "summary.md"), "---\ntitle: Hero\n---\n \n")
|
|
52
|
+
fail!("a blank hero file must hide the hero summary") unless Pandorga::HeroSummary.hidden?(dir)
|
|
53
|
+
fail!("a blank hero file must not fall back to the CV summary") unless Pandorga::HeroSummary.markdown(dir) == ""
|
|
54
|
+
|
|
55
|
+
FileUtils.rm_rf(cv)
|
|
56
|
+
FileUtils.rm_f(File.join(home, "summary.md"))
|
|
57
|
+
fail!("with neither file the hero must stay a fragment, not hide") if Pandorga::HeroSummary.hidden?(dir)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
tag = ROOT.join("lib/pandorga/jekyll/plugins/hero_summary.rb").read
|
|
61
|
+
fail!("hero tag ignores a blank hero file") unless tag.include?("HeroSummary.hidden?")
|
|
62
|
+
|
|
63
|
+
cv_layout = ROOT.join("_layouts/cv.html").read
|
|
64
|
+
fail!("CV page Summary must keep the cv/summary fragment") unless cv_layout.include?('data-content-fragment="cv/summary"')
|
|
65
|
+
fail!("CV page must not read the hero file") if cv_layout.include?("home/summary")
|
|
66
|
+
|
|
67
|
+
schema = ROOT.join("studio/schema.yml").read
|
|
68
|
+
hero_entry = schema[/- name: home_summary\n(?: .*\n)+/]
|
|
69
|
+
fail!("Studio has no Hero summary entry") unless hero_entry
|
|
70
|
+
fail!("Studio Hero summary label") unless hero_entry.include?("label: Hero summary")
|
|
71
|
+
fail!("Studio Hero summary path") unless hero_entry.include?("path: content/pages/home/summary.md")
|
|
72
|
+
fail!("Studio Hero summary is not a markdown body") unless hero_entry.include?("component: markdown_body")
|
|
73
|
+
presentation = schema[/- name: presentation\n.*?(?=\n - name: )/m]
|
|
74
|
+
fail!("Hero summary is not in the Presentation group") unless presentation&.include?("- name: home_summary")
|
|
75
|
+
fail!("Studio public CV entry is not labelled CV page") unless schema.match?(/- name: cv_summary_public\n\s+label: CV page\n/)
|
|
76
|
+
|
|
36
77
|
fallback = Pandorga::HeroSummary.element("")
|
|
37
78
|
fail!("missing summary must stay a quiet fragment") unless fallback.include?('data-content-fragment="cv/summary" data-content-quiet')
|
|
38
79
|
fail!("missing summary must not claim to be static") if fallback.include?("data-content-static")
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# PLT-AC-52. Home article cards keep tags and "Read" on one row, all use the
|
|
5
|
+
# band accent, and a band header whose texts are all empty is removed.
|
|
6
|
+
|
|
7
|
+
require "pathname"
|
|
8
|
+
|
|
9
|
+
ROOT = Pathname.new(__dir__).join("..", "..").expand_path
|
|
10
|
+
|
|
11
|
+
def fail!(message)
|
|
12
|
+
warn "FAIL test-home-article-cards: #{message}"
|
|
13
|
+
exit 1
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
blocks = ROOT.join("_includes/content-runtime/43-blocks.html").read
|
|
17
|
+
home_layout = ROOT.join("_layouts/home.html").read
|
|
18
|
+
css = ROOT.join("assets/css/ledger/home.css").read
|
|
19
|
+
band_index = ROOT.join("_includes/home/band-index.html").read
|
|
20
|
+
fragments = ROOT.join("_includes/content-runtime/60-fragments.html").read
|
|
21
|
+
|
|
22
|
+
# 1. Foot row.
|
|
23
|
+
card = blocks[/function renderArchiveCard\(.*?\n \}\n/m] or fail!("renderArchiveCard not found")
|
|
24
|
+
fail!("renderArchiveCard has no tagsInFoot option") unless card.include?("options.tagsInFoot")
|
|
25
|
+
chips_at = card.index("renderTagChips(")
|
|
26
|
+
foot_at = card.index("'<div class=\"ledger-card-foot\">'")
|
|
27
|
+
fail!("tag chips are not built before the foot") unless chips_at && foot_at && chips_at < foot_at
|
|
28
|
+
fail!("chips are not put inside the foot when tagsInFoot") unless card =~ /tagsInFoot[^\n]*\n?[^\n]*foot\s*\+?=\s*chips|foot\s*=\s*\(?options\.tagsInFoot/m
|
|
29
|
+
callers = Dir[ROOT.join("{_layouts,_includes}/**/*.html").to_s].select { |f| File.read(f).include?("tagsInFoot: true") }
|
|
30
|
+
fail!("tagsInFoot must be set by the home layout only, found #{callers.inspect}") unless callers.map { |f| File.basename(f) } == ["home.html"]
|
|
31
|
+
foot_rule = css[/^\.home-card-grid \.ledger-card-foot \{[^}]*\}/m] or fail!("no .home-card-grid .ledger-card-foot rule")
|
|
32
|
+
fail!(".home-card-grid .ledger-card-foot must space-between") unless foot_rule.include?("justify-content: space-between")
|
|
33
|
+
cta_rule = css[/^\.home-card-grid \.ledger-card-foot \.ledger-cta \{[^}]*\}/m]
|
|
34
|
+
fail!(".home-card-grid .ledger-card-foot .ledger-cta must take margin-left: auto") unless cta_rule&.include?("margin-left: auto")
|
|
35
|
+
|
|
36
|
+
# 2. One accent.
|
|
37
|
+
css.scan(/^([^{}\n]*\.home-card-grid[^{}\n]*)\{([^}]*)\}/m).each do |sel, body|
|
|
38
|
+
if body =~ /border-top-color:\s*var\(--color-(secondary|tertiary)\)/
|
|
39
|
+
fail!("#{sel.strip} rotates the card rule to --color-#{$1}")
|
|
40
|
+
end
|
|
41
|
+
fail!("#{sel.strip} keys a colour to :nth-child") if sel.include?("nth-child") && body =~ /color/
|
|
42
|
+
end
|
|
43
|
+
base = css[/^\.home-card-grid \.ledger-card \{[^}]*\}/m] or fail!("no .home-card-grid .ledger-card rule")
|
|
44
|
+
fail!("card top rule is not var(--color-primary)") unless base.include?("border-top-color: var(--color-primary)")
|
|
45
|
+
|
|
46
|
+
# 3. Build time.
|
|
47
|
+
fail!("band-index does not set data-home-label") unless band_index.include?("data-home-label=")
|
|
48
|
+
fail!("band-index does not guard the banner on empty header texts") unless band_index =~ /\{%-?\s*unless _hide_banner\s*-?%\}\s*<div class="ledger-section-banner"/m
|
|
49
|
+
fail!("band-index does not label the section when the banner is hidden") unless band_index =~ /_hide_banner\s*%\}\s*aria-label=/
|
|
50
|
+
|
|
51
|
+
# 4. Runtime.
|
|
52
|
+
apply = fragments[/function applyHomeSection\(.*?\n \}\n/m] or fail!("applyHomeSection not found")
|
|
53
|
+
fail!("applyHomeSection does not drop an emptied banner") unless apply.include?("root.remove()") &&
|
|
54
|
+
apply.include?("home-section-aside") && apply.include?("data-home-label")
|
|
55
|
+
|
|
56
|
+
puts "PASS test-home-article-cards"
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# PLT-AC-53: the object-store publish runs its prefix syncs in parallel and
|
|
5
|
+
# uploads manifest.json last, only when every job succeeded. Uses a stateful
|
|
6
|
+
# fake `aws` on PATH; no credentials or network.
|
|
7
|
+
|
|
8
|
+
$stdout.sync = true
|
|
9
|
+
$stderr.sync = true
|
|
10
|
+
|
|
11
|
+
require "fileutils"
|
|
12
|
+
require "json"
|
|
13
|
+
require "open3"
|
|
14
|
+
require "pathname"
|
|
15
|
+
require "tmpdir"
|
|
16
|
+
|
|
17
|
+
ROOT = Pathname.new(__dir__).join("../..").expand_path
|
|
18
|
+
SCRIPT = ROOT.join("scripts/content/publish-content-to-object-store.sh")
|
|
19
|
+
SYNC = ROOT.join("scripts/content/sync-content-json-to-r2.rb")
|
|
20
|
+
|
|
21
|
+
def fail!(message)
|
|
22
|
+
warn "FAIL test-parallel-content-publish: #{message}"
|
|
23
|
+
exit 1
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def check!(condition, message)
|
|
27
|
+
fail!(message) unless condition
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The fake keeps bucket objects under $FAKE_AWS_BUCKET_DIR and appends one
|
|
31
|
+
# JSON line per call ({argv, start, end}) to $FAKE_AWS_LOG. Listing sleeps so
|
|
32
|
+
# overlapping prefix syncs are observable. FAKE_AWS_FAIL_PREFIX makes the
|
|
33
|
+
# listing of that prefix fail.
|
|
34
|
+
FAKE_AWS = <<~'RUBY'
|
|
35
|
+
#!/usr/bin/env ruby
|
|
36
|
+
require "digest"
|
|
37
|
+
require "fileutils"
|
|
38
|
+
require "json"
|
|
39
|
+
bucket_dir = ENV.fetch("FAKE_AWS_BUCKET_DIR")
|
|
40
|
+
start = Process.clock_gettime(Process::CLOCK_REALTIME)
|
|
41
|
+
args = ARGV.dup
|
|
42
|
+
status = 0
|
|
43
|
+
key_of = ->(url) { url.sub(%r{\As3://[^/]+/?}, "") }
|
|
44
|
+
opt = ->(name) { (i = args.index(name)) ? args[i + 1] : nil }
|
|
45
|
+
case args[0, 2]
|
|
46
|
+
when %w[s3api list-objects-v2]
|
|
47
|
+
prefix = opt.call("--prefix").to_s
|
|
48
|
+
sleep 0.4
|
|
49
|
+
if (bad = ENV["FAKE_AWS_FAIL_PREFIX"].to_s) != "" && prefix == "#{bad}/"
|
|
50
|
+
warn "fake aws: listing #{prefix} failed"
|
|
51
|
+
status = 1
|
|
52
|
+
else
|
|
53
|
+
contents = Dir.glob(File.join(bucket_dir, "**", "*")).select { |f| File.file?(f) }.map do |f|
|
|
54
|
+
key = f.delete_prefix("#{bucket_dir}/")
|
|
55
|
+
next unless key.start_with?(prefix)
|
|
56
|
+
{ "Key" => key, "ETag" => "\"#{Digest::MD5.file(f).hexdigest}\"" }
|
|
57
|
+
end.compact
|
|
58
|
+
puts JSON.generate({ "Contents" => contents, "IsTruncated" => false })
|
|
59
|
+
end
|
|
60
|
+
when %w[s3 cp]
|
|
61
|
+
dest = File.join(bucket_dir, key_of.call(args[3]))
|
|
62
|
+
FileUtils.mkdir_p(File.dirname(dest))
|
|
63
|
+
FileUtils.cp(args[2], dest)
|
|
64
|
+
when %w[s3 rm]
|
|
65
|
+
FileUtils.rm_f(File.join(bucket_dir, key_of.call(args[2])))
|
|
66
|
+
when %w[s3 sync]
|
|
67
|
+
src = args[2]
|
|
68
|
+
prefix = key_of.call(args[3])
|
|
69
|
+
local = Dir.glob(File.join(src, "**", "*")).select { |f| File.file?(f) }.map { |f| f.delete_prefix("#{src}/") }
|
|
70
|
+
local.each do |rel|
|
|
71
|
+
dest = File.join(bucket_dir, prefix, rel)
|
|
72
|
+
FileUtils.mkdir_p(File.dirname(dest))
|
|
73
|
+
FileUtils.cp(File.join(src, rel), dest)
|
|
74
|
+
end
|
|
75
|
+
if args.include?("--delete")
|
|
76
|
+
Dir.glob(File.join(bucket_dir, prefix, "**", "*")).select { |f| File.file?(f) }.each do |f|
|
|
77
|
+
FileUtils.rm_f(f) unless local.include?(f.delete_prefix("#{File.join(bucket_dir, prefix)}/"))
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
else
|
|
81
|
+
warn "fake aws: unsupported #{args.inspect}"
|
|
82
|
+
status = 2
|
|
83
|
+
end
|
|
84
|
+
finish = Process.clock_gettime(Process::CLOCK_REALTIME)
|
|
85
|
+
File.open(ENV.fetch("FAKE_AWS_LOG"), "a") { |io| io.puts(JSON.generate({ "argv" => args, "start" => start, "end" => finish })) }
|
|
86
|
+
exit status
|
|
87
|
+
RUBY
|
|
88
|
+
|
|
89
|
+
check!(SCRIPT.file?, "missing #{SCRIPT}")
|
|
90
|
+
check!(!File.read(SYNC).include?("bash\", \"-lc\""),
|
|
91
|
+
"sync-content-json-to-r2.rb must check for aws on its own PATH, not a login shell's")
|
|
92
|
+
|
|
93
|
+
def write(path, body)
|
|
94
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
95
|
+
File.write(path, body)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def run_publish(tmp, fail_prefix: nil)
|
|
99
|
+
site = File.join(tmp, "site")
|
|
100
|
+
export = File.join(site, "_content_json")
|
|
101
|
+
bucket = File.join(tmp, "bucket")
|
|
102
|
+
bin = File.join(tmp, "bin")
|
|
103
|
+
log = File.join(tmp, "aws.log")
|
|
104
|
+
FileUtils.rm_rf([bucket, log])
|
|
105
|
+
write(File.join(export, "collections/articles/a.json"), %({"a":1}\n))
|
|
106
|
+
write(File.join(export, "data/b.json"), %({"b":1}\n))
|
|
107
|
+
write(File.join(export, "pages/c.json"), %({"c":1}\n))
|
|
108
|
+
write(File.join(export, "manifest.json"), %({"version":1}\n))
|
|
109
|
+
write(File.join(site, "content/media/images/x.png"), "png")
|
|
110
|
+
write(File.join(bucket, "collections/articles/stale.json"), "{}")
|
|
111
|
+
write(File.join(bucket, "media/old.png"), "old")
|
|
112
|
+
write(File.join(bin, "aws"), FAKE_AWS)
|
|
113
|
+
FileUtils.chmod(0o755, File.join(bin, "aws"))
|
|
114
|
+
|
|
115
|
+
env = ENV.to_h.reject { |k, _| k.start_with?("S3_", "R2_", "AWS_") }.merge(
|
|
116
|
+
"PATH" => "#{bin}:#{ENV.fetch('PATH')}",
|
|
117
|
+
"PANDORGA_SITE_ROOT" => site,
|
|
118
|
+
"S3_ENDPOINT" => "https://example.invalid",
|
|
119
|
+
"S3_BUCKET" => "test-bucket",
|
|
120
|
+
"S3_ACCESS_KEY_ID" => "test",
|
|
121
|
+
"S3_SECRET_ACCESS_KEY" => "test",
|
|
122
|
+
"FAKE_AWS_BUCKET_DIR" => bucket,
|
|
123
|
+
"FAKE_AWS_LOG" => log
|
|
124
|
+
)
|
|
125
|
+
env["FAKE_AWS_FAIL_PREFIX"] = fail_prefix if fail_prefix
|
|
126
|
+
out, status = Open3.capture2e(env, "bash", SCRIPT.to_s, export)
|
|
127
|
+
calls = File.file?(log) ? File.readlines(log).map { |l| JSON.parse(l) } : []
|
|
128
|
+
[out, status, calls, bucket]
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
Dir.mktmpdir("pandorga-parallel-publish") do |tmp|
|
|
132
|
+
out, status, calls, bucket = run_publish(tmp)
|
|
133
|
+
check!(status.success?, "publish failed (#{status.exitstatus}):\n#{out}")
|
|
134
|
+
|
|
135
|
+
objects = Dir.glob(File.join(bucket, "**", "*")).select { |f| File.file?(f) }
|
|
136
|
+
.map { |f| f.delete_prefix("#{bucket}/") }.sort
|
|
137
|
+
expected = %w[collections/articles/a.json data/b.json manifest.json media/images/x.png pages/c.json]
|
|
138
|
+
check!(objects == expected, "bucket objects #{objects.inspect}, expected #{expected.inspect}")
|
|
139
|
+
|
|
140
|
+
first_list = %w[collections data pages].to_h do |prefix|
|
|
141
|
+
call = calls.find { |c| c["argv"][0, 2] == %w[s3api list-objects-v2] && c["argv"].include?("#{prefix}/") }
|
|
142
|
+
check!(call, "no listing of #{prefix}/ in #{calls.map { |c| c['argv'].first(2) }.inspect}")
|
|
143
|
+
[prefix, call]
|
|
144
|
+
end
|
|
145
|
+
latest_start = first_list.values.map { |c| c["start"] }.max
|
|
146
|
+
earliest_end = first_list.values.map { |c| c["end"] }.min
|
|
147
|
+
check!(latest_start < earliest_end,
|
|
148
|
+
"collections/, data/ and pages/ syncs did not overlap; they must run in parallel")
|
|
149
|
+
|
|
150
|
+
manifest = calls.select { |c| c["argv"][0, 2] == %w[s3 cp] && c["argv"][3].to_s.end_with?("/manifest.json") }
|
|
151
|
+
check!(manifest.length == 1, "expected one manifest upload, got #{manifest.length}")
|
|
152
|
+
others = calls - manifest
|
|
153
|
+
check!(others.all? { |c| c["end"] <= manifest.first["start"] },
|
|
154
|
+
"manifest.json must be uploaded after every other call ended")
|
|
155
|
+
|
|
156
|
+
order = ["R2 replace sync collections/", "R2 replace sync data/", "R2 replace sync pages/",
|
|
157
|
+
"==> Sync media/", "==> Upload manifest.json", "==> Object-store publish complete"]
|
|
158
|
+
positions = order.map { |marker| out.index(marker) }
|
|
159
|
+
check!(positions.none?(&:nil?), "missing log markers #{order.zip(positions).select { |_, p| p.nil? }.map(&:first)}:\n#{out}")
|
|
160
|
+
check!(positions == positions.sort, "job logs must print in the fixed order #{order.inspect}:\n#{out}")
|
|
161
|
+
|
|
162
|
+
out, status, calls, = run_publish(tmp, fail_prefix: "data")
|
|
163
|
+
check!(!status.success?, "publish must fail when a prefix sync fails:\n#{out}")
|
|
164
|
+
check!(out.include?("listing data/ failed") || out.include?("list-objects-v2 failed for data/"),
|
|
165
|
+
"the failed job's log must be printed:\n#{out}")
|
|
166
|
+
check!(out.include?("data"), "the failure must name the failed job")
|
|
167
|
+
check!(calls.none? { |c| c["argv"][0, 2] == %w[s3 cp] && c["argv"][3].to_s.end_with?("/manifest.json") },
|
|
168
|
+
"manifest.json must not be uploaded when a job failed")
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
puts "PASS test-parallel-content-publish"
|
data/scripts/validate.sh
CHANGED
data/studio/schema.yml
CHANGED
|
@@ -823,7 +823,7 @@ content:
|
|
|
823
823
|
type: group
|
|
824
824
|
items:
|
|
825
825
|
- name: cv_summary_public
|
|
826
|
-
label:
|
|
826
|
+
label: CV page
|
|
827
827
|
type: file
|
|
828
828
|
path: content/pages/cv/summary.md
|
|
829
829
|
format: raw
|
|
@@ -862,6 +862,16 @@ content:
|
|
|
862
862
|
- name: body
|
|
863
863
|
component: markdown_body
|
|
864
864
|
|
|
865
|
+
- name: home_summary
|
|
866
|
+
label: Hero summary
|
|
867
|
+
type: file
|
|
868
|
+
path: content/pages/home/summary.md
|
|
869
|
+
format: raw
|
|
870
|
+
description: "Home hero paragraph. Without this file the hero shows the CV page summary; saved empty, the hero shows none."
|
|
871
|
+
fields:
|
|
872
|
+
- name: body
|
|
873
|
+
component: markdown_body
|
|
874
|
+
|
|
865
875
|
- name: cv_ai_practice
|
|
866
876
|
label: AI practice
|
|
867
877
|
type: file
|
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: 2.0.
|
|
4
|
+
version: 2.0.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- The Pandorga contributors
|
|
@@ -439,6 +439,7 @@ files:
|
|
|
439
439
|
- scripts/test/test-hero-portraits.rb
|
|
440
440
|
- scripts/test/test-hero-summary.rb
|
|
441
441
|
- scripts/test/test-highlighter-pills.rb
|
|
442
|
+
- scripts/test/test-home-article-cards.rb
|
|
442
443
|
- scripts/test/test-home-articles.rb
|
|
443
444
|
- scripts/test/test-home-band-art.rb
|
|
444
445
|
- scripts/test/test-home-band-intro.rb
|
|
@@ -453,6 +454,7 @@ files:
|
|
|
453
454
|
- scripts/test/test-math-row-breaks.rb
|
|
454
455
|
- scripts/test/test-new.rb
|
|
455
456
|
- scripts/test/test-no-personal-data.rb
|
|
457
|
+
- scripts/test/test-parallel-content-publish.rb
|
|
456
458
|
- scripts/test/test-poster-srcset.mjs
|
|
457
459
|
- scripts/test/test-public-cv-export.rb
|
|
458
460
|
- scripts/test/test-publish-site-root.rb
|