pandorga 2.0.1 → 2.0.3

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: 8ae9f3cb8f8c39b2e9af7ee8b582a35cdc389f61302704888d3c85adb88de394
4
- data.tar.gz: 0d313d288d44e2d249751ceda633d6f58d981c42561a116c3faad0aac8e2cd9b
3
+ metadata.gz: 221dacb578ada5bb29237157b9e2fcffc4789053fe0566eae9f626967334b291
4
+ data.tar.gz: eedd7e320f83cf654525e469eddf66f013f7229324304d13ed983ad38a0b58eb
5
5
  SHA512:
6
- metadata.gz: 91f82a29a1d5b7890fc2955ba3522d35f5c992a57dfda3b2f6e815e7ca613278aa1910be945170351d82ce5675045994070c6a6c8e9a8e20c3bf8d7b2448736d
7
- data.tar.gz: 7478438f607e96460805455bf6873b0a97ccf35804a215f0d5422a168eb91228a91ef6907d9240b8d63f173e1cebc031c45070d0c8dfa4ae09b3ab8bf05ac088
6
+ metadata.gz: 41b852a904d013dbc020d1d14fe33b56d9355a5cf201255fc14df14ae095c2457da292ba2f357cce007b45a886fd047b2c9312a4bc1d3eb9f2f69b995270845c
7
+ data.tar.gz: d48976bdd3f548a0480766c9e6449566ce3527c5e78bc603711681d32c0ab144d6a8c5c7c60c02fd65553a1160c02eb3f10a8791e284ec0e3c98cdd4019f7f19
data/CHANGELOG.md CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [2.0.3] — 2026-10-25
6
+
7
+ ### Fixed
8
+
9
+ - Entry titles on the Articles, Sources and Bibliography pages use the Rascunhos size at every width. The Articles rows and Sources cards were a step smaller and shrank again below 768px, and the Bibliography cards were a step smaller (PLT-AC-55).
10
+
11
+ ## [2.0.2] — 2026-10-25
12
+
13
+ ### Added
14
+
15
+ - 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).
16
+
17
+ ### Changed
18
+
19
+ - 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).
20
+
5
21
  ## [2.0.1] — 2026-10-24
6
22
 
7
23
  ### Fixed
data/DESIGN.md CHANGED
@@ -762,7 +762,7 @@ those tokens — no one-off rem sizes for roles that already have a step.
762
762
  | Page H1 | Cinzel Decorative | 400 | `--type-page` / `-lg` |
763
763
  | Section | Cinzel Decorative | 400 | `--type-section` / `-lg` |
764
764
  | Detail title | Newsreader | 600 | `--type-title` / `-lg` |
765
- | Card / row title | Newsreader | 600 | `--type-card` / `-lg` |
765
+ | Card / row title | Newsreader | 600 | `--type-card` / `-lg`; listing entry titles always `-lg` (PLT-AC-55) |
766
766
  | Prose | Newsreader | 300 | `--type-prose` |
767
767
  | UI body | Newsreader | 300 | `--type-body` |
768
768
  | Detail standfirst | Newsreader | 300 | `--type-subtitle` (= body − 1pt) |
data/assets/css/base.css CHANGED
@@ -527,11 +527,11 @@ html[data-theme="dark"] {
527
527
  --color-link-hover: color-mix(in srgb, var(--color-tertiary) 72%, var(--color-on-surface));
528
528
  }
529
529
 
530
- /* Card / row / collection entry titles — content-title voice (Regular 400). */
530
+ /* Card / row / collection entry titles — content-title voice (Regular 400).
531
+ Listing rows and source cards also carry .card-title, so they get the voice
532
+ here and their size from the listing entry rule below. */
531
533
  .card-title,
532
- .ledger-card-title,
533
- .ledger-row-title,
534
- .source-card-title {
534
+ .ledger-card-title {
535
535
  font-family: var(--font-content-title);
536
536
  font-size: var(--type-card);
537
537
  font-weight: var(--weight-title);
@@ -554,6 +554,17 @@ html[data-theme="dark"] {
554
554
  }
555
555
  }
556
556
 
557
+ /* Listing entry titles (PLT-AC-55). The Articles and Rascunhos rows and the
558
+ Sources and Bibliography cards set their entry title at the Rascunhos step
559
+ at every width. This rule follows the small-screen step above on purpose,
560
+ so it holds below 768px too. */
561
+ .ledger-row-title,
562
+ .source-card-title,
563
+ .bib-card-title {
564
+ font-size: var(--type-card-lg);
565
+ line-height: 1.25;
566
+ }
567
+
557
568
  /* Label-face meta on CV identity lines, etc. */
558
569
  .card-meta {
559
570
  font-family: var(--font-label);
@@ -138,8 +138,6 @@
138
138
 
139
139
  .bib-card-title {
140
140
  margin: 0;
141
- font-size: var(--type-card);
142
- line-height: 1.25;
143
141
  color: var(--color-on-surface);
144
142
  overflow-wrap: anywhere;
145
143
  }
@@ -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/cv/summary.md exists. */
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);
@@ -204,13 +204,6 @@
204
204
  text-transform: uppercase;
205
205
  }
206
206
 
207
- /* Entry titles — a step above the shared ledger-row scale so the notebook
208
- line reads as a title, not a caption. */
209
- .rascunhos-row .ledger-row-title {
210
- font-size: var(--type-card-lg);
211
- line-height: 1.25;
212
- }
213
-
214
207
  /* ══ Ledger entry ══
215
208
  Ruled paper, not a stack of cards: the shared row's card fill and padding
216
209
  come off, and what separates two entries is the hairline under each one.
@@ -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.1"
27
+ # GitHub tag: gem "pandorga", github: "alexandrelheinen/pandorga", tag: "v2.0.3"
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/cv/summary.md.
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(RELATIVE, root)
33
+ path = File.expand_path(relative, root)
19
34
  prefix = root.end_with?(File::SEPARATOR) ? root : root + File::SEPARATOR
20
- return "" unless path.start_with?(prefix)
21
- return "" unless File.file?(path)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pandorga
4
- VERSION = "2.0.1"
4
+ VERSION = "2.0.3"
5
5
  end
@@ -58,36 +58,71 @@ run_ruby() {
58
58
 
59
59
  sync_json_prefix() {
60
60
  local prefix="$1"
61
- local src="$EXPORT_ROOT/$prefix"
62
- if [ -d "$src" ]; then
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
- if [ -d "$MEDIA_ROOT" ]; then
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", "-lc", "command -v aws").last.exitstatus != 0
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,107 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # PLT-AC-55: listing entry titles on the Articles, Rascunhos, Sources and
5
+ # Bibliography pages share one size, the Rascunhos step, at every width.
6
+
7
+ require "pathname"
8
+
9
+ ROOT = Pathname.new(__dir__).join("..", "..").expand_path
10
+ CSS_ROOT = ROOT.join("assets/css")
11
+ TITLE_CLASSES = %w[.ledger-row-title .source-card-title .bib-card-title].freeze
12
+
13
+ def fail!(message)
14
+ warn "FAIL: #{message}"
15
+ exit 1
16
+ end
17
+
18
+ def strip_comments(source)
19
+ source.gsub(%r{/\*.*?\*/}m, "")
20
+ end
21
+
22
+ # Yields [selectors, declarations, offset] for every rule, descending into
23
+ # at-rule blocks such as media queries.
24
+ def each_rule(source, &block)
25
+ depth_start = 0
26
+ i = 0
27
+ while (open = source.index("{", i))
28
+ prelude = source[i...open].strip
29
+ depth = 1
30
+ j = open + 1
31
+ while depth.positive? && j < source.length
32
+ depth += 1 if source[j] == "{"
33
+ depth -= 1 if source[j] == "}"
34
+ j += 1
35
+ end
36
+ body = source[(open + 1)...(j - 1)]
37
+ if prelude.start_with?("@")
38
+ each_rule(body, &block) if body.include?("{")
39
+ else
40
+ selectors = prelude.split(",").map { |s| s.strip.gsub(/\s+/, " ") }
41
+ yield selectors, body, open + depth_start
42
+ end
43
+ i = j
44
+ end
45
+ end
46
+
47
+ def last_compound(selector)
48
+ selector.split(/\s*[ >+~]\s*/).last.to_s
49
+ end
50
+
51
+ def declares?(body, property)
52
+ body.match?(/(?:^|;|\s)#{Regexp.escape(property)}\s*:/)
53
+ end
54
+
55
+ puts "Testing listing entry titles (PLT-AC-55)..."
56
+
57
+ base = strip_comments(CSS_ROOT.join("base.css").read)
58
+ shared = []
59
+ each_rule(base) do |selectors, body, offset|
60
+ shared << [body, offset] if selectors.sort == TITLE_CLASSES.sort
61
+ end
62
+ fail!("base.css needs exactly one rule for #{TITLE_CLASSES.join(', ')}") unless shared.size == 1
63
+ body, shared_offset = shared.first
64
+ fail!("shared rule must set font-size: var(--type-card-lg)") unless body.match?(/font-size:\s*var\(--type-card-lg\)\s*;/)
65
+ fail!("shared rule must set line-height: 1.25") unless body.match?(/line-height:\s*1\.25\s*;/)
66
+
67
+ small_step = base.index("var(--type-card-sm)", base.index("--type-card-sm:") + 1)
68
+ fail!("small-screen .card-title step not found in base.css") unless small_step
69
+ fail!("shared rule must follow the small-screen .card-title step") unless shared_offset > small_step
70
+ puts " shared rule: OK"
71
+
72
+ offenders = []
73
+ Dir.glob(CSS_ROOT.join("**", "*.css")).sort.each do |path|
74
+ source = strip_comments(File.read(path))
75
+ each_rule(source) do |selectors, rule_body, _offset|
76
+ next if selectors.sort == TITLE_CLASSES.sort && path == CSS_ROOT.join("base.css").to_s
77
+
78
+ targeted = selectors.select do |sel|
79
+ compound = last_compound(sel)
80
+ TITLE_CLASSES.any? { |cls| compound.include?(cls) && !compound.match?(/#{Regexp.escape(cls)}[-\w]/) }
81
+ end
82
+ next if targeted.empty?
83
+
84
+ %w[font-size line-height].each do |prop|
85
+ next unless declares?(rule_body, prop)
86
+
87
+ offenders << "#{Pathname.new(path).relative_path_from(ROOT)}: #{targeted.join(', ')} sets #{prop}"
88
+ end
89
+ end
90
+ end
91
+ fail!("entry title overrides:\n #{offenders.join("\n ")}") unless offenders.empty?
92
+ puts " no overrides: OK"
93
+
94
+ markup = {
95
+ "_includes/content-runtime/43-blocks.html" => "ledger-row-title",
96
+ "_layouts/resources.html" => "source-card-title",
97
+ "_layouts/bibliography.html" => "bib-card-title"
98
+ }
99
+ markup.each do |file, cls|
100
+ fail!("#{file} no longer emits #{cls}") unless ROOT.join(file).read.include?(cls)
101
+ end
102
+ %w[_layouts/articles.html _layouts/blog.html].each do |file|
103
+ fail!("#{file} no longer renders rows with renderLedgerRow") unless ROOT.join(file).read.include?("renderLedgerRow(")
104
+ end
105
+ puts " markup: OK"
106
+
107
+ puts "All listing entry title checks passed."
@@ -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
@@ -62,6 +62,7 @@ TESTS=(
62
62
  scripts/test/test-theme-contrast.rb
63
63
  scripts/test/test-hydrate-reserve.rb
64
64
  scripts/test/test-hero-summary.rb
65
+ scripts/test/test-listing-entry-titles.rb
65
66
  scripts/test/test-lighthouse-policy.rb
66
67
  scripts/test/test-home-lists.rb
67
68
  scripts/test/test-home-articles.rb
@@ -78,6 +79,7 @@ TESTS=(
78
79
  scripts/test/test-v3-layout.rb
79
80
  scripts/test/test-studio-section-texts.rb
80
81
  scripts/test/test-home-article-cards.rb
82
+ scripts/test/test-parallel-content-publish.rb
81
83
  )
82
84
 
83
85
  failed=0
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: Public
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.1
4
+ version: 2.0.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - The Pandorga contributors
@@ -449,11 +449,13 @@ files:
449
449
  - scripts/test/test-language-export.rb
450
450
  - scripts/test/test-ledger-blocks.rb
451
451
  - scripts/test/test-lighthouse-policy.rb
452
+ - scripts/test/test-listing-entry-titles.rb
452
453
  - scripts/test/test-listing-filter-contract.rb
453
454
  - scripts/test/test-math-row-breaks.mjs
454
455
  - scripts/test/test-math-row-breaks.rb
455
456
  - scripts/test/test-new.rb
456
457
  - scripts/test/test-no-personal-data.rb
458
+ - scripts/test/test-parallel-content-publish.rb
457
459
  - scripts/test/test-poster-srcset.mjs
458
460
  - scripts/test/test-public-cv-export.rb
459
461
  - scripts/test/test-publish-site-root.rb