pandorga 1.7.0 → 1.7.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.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +50 -0
  3. data/DESIGN.md +9 -9
  4. data/_data/themes/dark.yml +2 -1
  5. data/_includes/content-runtime/30-includes.html +1 -1
  6. data/_includes/content-runtime/43-blocks.html +1 -1
  7. data/_includes/content-runtime/50-detail.html +5 -5
  8. data/_includes/content-runtime/60-fragments.html +3 -3
  9. data/_includes/page/network-graph.html +2 -2
  10. data/_includes/page/page-header.html +3 -1
  11. data/_includes/page/site-footer.html +2 -2
  12. data/_includes/theme/json-ld.html +35 -2
  13. data/_layouts/articles.html +1 -1
  14. data/_layouts/projects.html +2 -2
  15. data/_layouts/resources.html +1 -1
  16. data/_layouts/shell.html +4 -4
  17. data/_layouts/text.html +4 -4
  18. data/_plugins/lib/writing_entry.rb +1 -1
  19. data/_plugins/local_detail_rewrites.rb +10 -1
  20. data/assets/css/article.css +3 -3
  21. data/assets/css/base.css +0 -79
  22. data/assets/css/global-link.css +0 -1
  23. data/assets/css/ledger/chrome.css +1 -1
  24. data/assets/css/ledger/cv.css +1 -1
  25. data/assets/css/ledger/detail.css +1 -1
  26. data/assets/css/ledger/footer.css +1 -1
  27. data/assets/css/ledger/home.css +13 -42
  28. data/assets/css/ledger/projects.css +2 -2
  29. data/assets/css/ledger/sources.css +3 -3
  30. data/assets/css/ledger/writing.css +1 -1
  31. data/lib/pandorga/detail_routes.rb +29 -0
  32. data/lib/pandorga/jekyll/plugins/detail_documents.rb +154 -0
  33. data/lib/pandorga/jekyll/plugins/lib/writing_entry.rb +1 -1
  34. data/lib/pandorga/jekyll/plugins/local_detail_rewrites.rb +10 -1
  35. data/lib/pandorga/version.rb +1 -1
  36. data/scripts/content/export-content-json.rb +1 -1
  37. data/scripts/test/render-parity/load-runtime.mjs +1 -1
  38. data/scripts/test/test-css-structure.rb +20 -0
  39. data/scripts/test/test-discovery.rb +36 -0
  40. data/scripts/test/test-examples-build.rb +90 -0
  41. data/scripts/test/test-fonts.rb +14 -0
  42. data/scripts/test/test-hero-portraits.mjs +12 -2
  43. data/scripts/test/test-hero-portraits.rb +12 -4
  44. data/scripts/test/test-lighthouse-policy.rb +4 -0
  45. data/scripts/test/test-listing-filter-contract.rb +1 -1
  46. data/scripts/test/test-shell-a11y.rb +27 -0
  47. data/scripts/test/test-theme-contrast.rb +43 -0
  48. data/scripts/validate.sh +6 -0
  49. metadata +4 -1
@@ -33,8 +33,23 @@ end
33
33
  FileUtils.rm_rf(example.join("_site"))
34
34
  FileUtils.rm_rf(example.join("_content_json"))
35
35
 
36
+ redirects_path = example.join("_redirects")
37
+ original_redirects = redirects_path.file? ? redirects_path.read : nil
38
+
39
+ begin
36
40
  run!("bundle", "install", "--quiet", chdir: example) if example.join("Gemfile").file?
37
41
  run!("ruby", EXE.to_s, "export", "content", "_content_json", chdir: example)
42
+ if name == "full" && redirects_path.file?
43
+ splat = <<~TXT
44
+
45
+ /articles/:slug /pages/articles/ 200
46
+ /posts/:slug /pages/blog/ 200
47
+ /blog/:slug /pages/blog/ 200
48
+ /projects/:slug /pages/projects/ 200
49
+ /products/:slug /pages/cv/ 200
50
+ TXT
51
+ redirects_path.write("#{redirects_path.read.rstrip}\n#{splat}")
52
+ end
38
53
  run!("bundle", "exec", "jekyll", "build", "--quiet", chdir: example)
39
54
 
40
55
  index = example.join("_site/index.html")
@@ -65,6 +80,18 @@ end
65
80
  warn "FAIL test-examples-build: full home still hydrates the summary"
66
81
  exit 1
67
82
  end
83
+ {
84
+ "pages/articles/index.html" => "Articles",
85
+ "pages/blog/index.html" => "Rascunhos",
86
+ "pages/projects/index.html" => "Projects"
87
+ }.each do |rel, title|
88
+ listing = example.join("_site", rel).read
89
+ heading = listing[/<h1 class="page-header-title.*?<\/h1>/m].to_s
90
+ unless heading.include?(title) && heading.include?("data-content-static") && !heading.include?("Loading...")
91
+ warn "FAIL test-examples-build: #{rel} static h1 is #{heading.inspect}"
92
+ exit 1
93
+ end
94
+ end
68
95
  else
69
96
  unless html.include?('id="home-cv-summary" class="home-hero-summary font-body" data-content-fragment="cv/summary" data-content-quiet')
70
97
  warn "FAIL test-examples-build: minimal home dropped the summary fallback"
@@ -86,6 +113,66 @@ end
86
113
  warn "FAIL test-examples-build: #{name} robots missing the sitemap line\n#{robots}"
87
114
  exit 1
88
115
  end
116
+ if name == "full"
117
+ {
118
+ "/articles/2026-01-01-welcome/" => "Article",
119
+ "/posts/2026-01-02-note/" => "BlogPosting"
120
+ }.each do |path, schema|
121
+ loc = "#{origin}#{path}"
122
+ unless sitemap.include?("<loc>#{loc}</loc>")
123
+ warn "FAIL test-examples-build: sitemap missing #{loc}\n#{sitemap}"
124
+ exit 1
125
+ end
126
+ page = example.join("_site#{path}index.html")
127
+ unless page.file?
128
+ warn "FAIL test-examples-build: missing static detail #{page}"
129
+ exit 1
130
+ end
131
+ body = page.read
132
+ canonical = body[/rel="canonical" href="([^"]+)"/, 1].to_s
133
+ og_url = body[/property="og:url" content="([^"]+)"/, 1].to_s
134
+ unless canonical.include?(path) && !canonical.include?("/pages/")
135
+ warn "FAIL test-examples-build: canonical for #{path} is #{canonical}"
136
+ exit 1
137
+ end
138
+ unless og_url.include?(path) && !og_url.include?("/pages/")
139
+ warn "FAIL test-examples-build: og:url for #{path} is #{og_url}"
140
+ exit 1
141
+ end
142
+ unless body.include?(%("@type": "#{schema}"))
143
+ warn "FAIL test-examples-build: #{path} missing #{schema} JSON-LD"
144
+ exit 1
145
+ end
146
+ listing_attr = path.include?("/posts/") ? "/pages/blog/" : "/pages/articles/"
147
+ unless body.include?(%(data-listing-path="#{listing_attr}"))
148
+ warn "FAIL test-examples-build: #{path} lost the listing path"
149
+ exit 1
150
+ end
151
+ end
152
+ ["/projects/demo-kit/", "/products/ledger-tool/"].each do |path|
153
+ loc = "#{origin}#{path}"
154
+ unless sitemap.include?("<loc>#{loc}</loc>")
155
+ warn "FAIL test-examples-build: sitemap missing #{loc}\n#{sitemap}"
156
+ exit 1
157
+ end
158
+ page = example.join("_site#{path}index.html")
159
+ body = page.file? ? page.read : ""
160
+ canonical = body[/rel="canonical" href="([^"]+)"/, 1].to_s
161
+ unless canonical.include?(path)
162
+ warn "FAIL test-examples-build: canonical for #{path} is #{canonical}"
163
+ exit 1
164
+ end
165
+ end
166
+ built_redirects = example.join("_site/_redirects").read
167
+ if built_redirects.match?(%r{/(?:articles|posts|blog|projects|products)/:slug})
168
+ warn "FAIL test-examples-build: built _redirects still rewrites detail URLs\n#{built_redirects}"
169
+ exit 1
170
+ end
171
+ unless redirects_path.read.match?(%r{/articles/:slug\s+/pages/articles/\s+200})
172
+ warn "FAIL test-examples-build: the build edited the source _redirects"
173
+ exit 1
174
+ end
175
+ end
89
176
  unless html.include?('application/ld+json') && html.include?('"@type": "Person"')
90
177
  warn "FAIL test-examples-build: #{name} home is missing JSON-LD"
91
178
  exit 1
@@ -99,6 +186,9 @@ end
99
186
  warn "FAIL test-examples-build: #{name} home dropped the title face"
100
187
  exit 1
101
188
  end
189
+ ensure
190
+ redirects_path.write(original_redirects) if original_redirects
191
+ end
102
192
  end
103
193
 
104
194
  puts "PASS test-examples-build"
@@ -47,6 +47,20 @@ fail!("body weights were not sorted") unless custom_href.include?("family=Source
47
47
  fail!("mono italic axis is wrong") unless custom_href.include?("family=IBM+Plex+Mono:ital,wght@0,400;0,500;1,400;1,500")
48
48
  fail!("unset label left Marcellus") unless custom_href.include?("family=Marcellus+SC:wght@400;700")
49
49
  fail!("body change left the tagline on Newsreader") unless Pandorga::Fonts.css_stack(custom, "subtitle").start_with?("'Source Serif 4'")
50
+
51
+ # A font rename once replaced the letters "hind" inside the word "behind".
52
+ token = "be" + "Newsreader"
53
+ broken = []
54
+ Dir.glob(ROOT.join("**/*.{css,html,rb,js,mjs,md,liquid,yml}")).each do |path|
55
+ next if path.include?("/studio/assets/")
56
+ next if path.include?("/node_modules/")
57
+ next if path.end_with?("test-fonts.rb")
58
+ text = File.read(path)
59
+ next unless text.include?(token)
60
+
61
+ broken << Pathname.new(path).relative_path_from(ROOT).to_s
62
+ end
63
+ fail!("corrupted 'behind' left by a font rename (#{broken.size} files): #{broken.join(', ')}") unless broken.empty?
50
64
  fail!("code face did not follow mono") unless Pandorga::Fonts.css_stack(custom, "code").start_with?("'IBM Plex Mono'")
51
65
 
52
66
  kept = Pandorga::Fonts.resolve({ "title" => "Cinzel Decorative" }, theme)
@@ -13,14 +13,24 @@ const source = fs.readFileSync(
13
13
  );
14
14
 
15
15
  const start = source.indexOf('function heroPortraitEntries');
16
- const end = source.indexOf('function applyHeroPortrait(');
16
+ // applyHeroPortraitSources is defined before pickHeroPortrait. Ending at
17
+ // applyHeroPortrait( still works today, and it is one character away from
18
+ // stopping on the Sources function. showHeroPortrait is the next function
19
+ // after the picker, so the slice always includes pickHeroPortrait.
20
+ const end = source.indexOf('function showHeroPortrait');
17
21
  if (start < 0 || end < start) {
18
22
  console.error('hero portrait functions missing from 60-fragments.html');
19
23
  process.exit(1);
20
24
  }
21
25
 
26
+ const slice = source.slice(start, end);
27
+ if (!slice.includes('function pickHeroPortrait')) {
28
+ console.error('hero portrait crop stopped before pickHeroPortrait');
29
+ process.exit(1);
30
+ }
31
+
22
32
  const api = new Function(
23
- `${source.slice(start, end)}\nreturn { heroPortraitEntries, pickHeroPortrait };`
33
+ `${slice}\nreturn { heroPortraitEntries, pickHeroPortrait };`
24
34
  )();
25
35
 
26
36
  const { heroPortraitEntries, pickHeroPortrait } = api;
@@ -37,13 +37,19 @@ puts "Testing home hero portraits..."
37
37
 
38
38
  # ── AC-HERO-01 ──────────────────────────────────────────────────────────────
39
39
 
40
- check!(POOL.directory?, "missing #{POOL}")
41
40
  check!(!ROOT.join("content/pages/home/portraits.yml").exist?,
42
41
  "the author does not keep a portrait list")
43
42
 
44
- pool_names = POOL.children.select(&:file?).map { |path| path.basename.to_s }.sort
45
- check!(pool_names == %w[DSC00571.JPG DSC00632.JPG DSC00721.JPG DSC00727.JPG],
46
- "the hero folder must hold the four uploaded photographs, got #{pool_names.inspect}")
43
+ # The four photographs live with the site, not in the gem. When they are
44
+ # here, the folder must be exactly that set. When they are not, the picker
45
+ # extracted from the runtime still has to run.
46
+ personal_pool = %w[DSC00571.JPG DSC00632.JPG DSC00721.JPG DSC00727.JPG]
47
+ pool_names = POOL.directory? ? POOL.children.select(&:file?).map { |path| path.basename.to_s }.sort : []
48
+ if pool_names.empty?
49
+ puts "AC-HERO-01: no hero photographs in this checkout"
50
+ elsif pool_names != personal_pool
51
+ check!(false, "the hero folder must hold the four uploaded photographs, got #{pool_names.inspect}")
52
+ end
47
53
 
48
54
  Dir.mktmpdir("hero-pool") do |dir|
49
55
  root = Pathname.new(dir)
@@ -135,6 +141,7 @@ check!(status.success?, "picker cases failed")
135
141
 
136
142
  # ── AC-HERO-02 ──────────────────────────────────────────────────────────────
137
143
 
144
+ if pool_names == personal_pool
138
145
  Dir.mktmpdir("hero-portraits") do |dir|
139
146
  out = Pathname.new(dir).join("export")
140
147
  env = { "CONTENT_API_BASE_URL" => "", "CONTENT_EXPORT_SKIP_VALIDATE" => "1" }
@@ -158,5 +165,6 @@ Dir.mktmpdir("hero-portraits") do |dir|
158
165
  check!(listed.include?("pages/home/portraits.json"),
159
166
  "manifest page_headers must include the portrait list, got #{listed.inspect}")
160
167
  end
168
+ end
161
169
 
162
170
  puts "All home hero portrait checks passed."
@@ -101,6 +101,10 @@ fail!("band note is still painted by script") unless band.include?("page_headers
101
101
  fail!("project inventory has a name and no role") unless band.include?('role="region"') && band.include?('aria-label="Project inventory"')
102
102
  headers = File.read(File.expand_path("../../_plugins/content_headers.rb", __dir__))
103
103
  fail!("page headers are not loaded at build time") unless headers.include?('site.data["page_headers"]')
104
+ page_header = File.read(File.expand_path("../../_includes/page/page-header.html", __dir__))
105
+ fail!("listing h1 is still Loading...") if page_header.include?("Loading...")
106
+ fail!("listing h1 does not read page headers") unless page_header.include?("page_headers")
107
+ fail!("listing h1 is not marked static") unless page_header.include?("data-content-static")
104
108
  home = File.read(File.expand_path("../../_layouts/home.html", __dir__))
105
109
  fail!("hero subtitle is still painted by script") unless home.include?("page_headers.home") && home.include?("data-content-static")
106
110
 
@@ -7,7 +7,7 @@
7
7
  # on click; `filterItems` reads a fixed set of names. Nothing connects the
8
8
  # two, so renaming a facet in a layout — `category` to `tag`, say — leaves the
9
9
  # chips rendering, highlighting and pushing a query string while the list
10
- # beNewsreader them never changes. That shipped on the Sources page: every click set
10
+ # behind them never changes. That shipped on the Sources page: every click set
11
11
  # a key nothing read.
12
12
  #
13
13
  # There is no build error and no console error to notice, so the contract is
@@ -27,4 +27,31 @@ fail!("footer home link still uses site.author alone") if FOOTER.include?(bare)
27
27
  fail!("shell home link needs an identity.name fallback") unless SHELL.include?("site.pandorga.identity.name")
28
28
  fail!("footer home link needs an identity.name fallback") unless FOOTER.include?("site.pandorga.identity.name")
29
29
 
30
+ fail!("top bar is still a nav") if SHELL.include?('<nav class="sticky')
31
+ fail!("top bar is not a header") unless SHELL.include?('<header class="sticky top-0 z-50 bg-surface">')
32
+ fail!("section links lost their name") unless SHELL.include?('aria-label="Sections"')
33
+ navs = SHELL.scan(/<nav\b/).size
34
+ fail!("shell should have one nav landmark, found #{navs}") unless navs == 1
35
+
36
+ # Decorative Material Symbols sit beside a text label. Without aria-hidden
37
+ # the ligature ("arrow_left_alt", "shuffle", an icon name) is read aloud.
38
+ ICON_FILES = [
39
+ ROOT.join("_layouts/text.html"),
40
+ ROOT.join("_includes/content-runtime/50-detail.html"),
41
+ ROOT.join("_includes/content-runtime/60-fragments.html"),
42
+ ROOT.join("_includes/content-runtime/30-includes.html")
43
+ ].freeze
44
+
45
+ ICON_FILES.each do |path|
46
+ path.read.each_line.with_index(1) do |line, number|
47
+ next unless line.include?("material-symbols-outlined")
48
+ next if line.include?("querySelector")
49
+ next if line.include?("aria-hidden")
50
+ # Class token handed to a later span that already sets aria-hidden.
51
+ next if line.include?("cls:")
52
+
53
+ fail!("#{path.relative_path_from(ROOT)}:#{number}: decorative icon is exposed")
54
+ end
55
+ end
56
+
30
57
  puts "PASS test-shell-a11y"
@@ -0,0 +1,43 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Dark tertiary container text must clear WCAG AA for normal text.
5
+ # PLT-AC-33
6
+
7
+ require "pathname"
8
+ require "yaml"
9
+
10
+ ROOT = Pathname.new(__dir__).join("..", "..").expand_path
11
+
12
+ def fail!(message)
13
+ warn "FAIL test-theme-contrast: #{message}"
14
+ exit 1
15
+ end
16
+
17
+ def channel(value)
18
+ value = value / 255.0
19
+ value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055)**2.4
20
+ end
21
+
22
+ def luminance(hex)
23
+ rgb = hex.delete("#").scan(/../).map { |part| part.to_i(16) }
24
+ fail!("not a hex colour: #{hex}") unless rgb.length == 3
25
+
26
+ (0.2126 * channel(rgb[0])) + (0.7152 * channel(rgb[1])) + (0.0722 * channel(rgb[2]))
27
+ end
28
+
29
+ def contrast(foreground, background)
30
+ lighter, darker = [luminance(foreground), luminance(background)].sort.reverse
31
+ (lighter + 0.05) / (darker + 0.05)
32
+ end
33
+
34
+ dark = YAML.safe_load(ROOT.join("_data/themes/dark.yml").read) || {}
35
+ colors = dark["colors"] || {}
36
+ background = colors["tertiary_container"].to_s
37
+ foreground = colors["on_tertiary_container"].to_s
38
+ fail!("dark tertiary container moved") unless background.casecmp("#6a684f").zero?
39
+
40
+ ratio = contrast(foreground, background)
41
+ fail!("#{foreground} on #{background} is #{ratio.round(2)}:1") unless ratio >= 4.5
42
+
43
+ puts "PASS test-theme-contrast (#{foreground} on #{background} = #{ratio.round(2)}:1)"
data/scripts/validate.sh CHANGED
@@ -57,6 +57,7 @@ TESTS=(
57
57
  scripts/test/test-examples-build.rb
58
58
  scripts/test/test-validate-allowlist.rb
59
59
  scripts/test/test-shell-a11y.rb
60
+ scripts/test/test-theme-contrast.rb
60
61
  scripts/test/test-hydrate-reserve.rb
61
62
  scripts/test/test-hero-summary.rb
62
63
  scripts/test/test-lighthouse-policy.rb
@@ -82,6 +83,11 @@ for test in "${TESTS[@]}"; do
82
83
  fi
83
84
  done
84
85
 
86
+ echo "-- scripts/test/test-hero-portraits.mjs"
87
+ if ! node scripts/test/test-hero-portraits.mjs; then
88
+ failed=1
89
+ fi
90
+
85
91
  echo "-- scripts/test/test-studio-pipeline-config.mjs"
86
92
  if ! node scripts/test/test-studio-pipeline-config.mjs; then
87
93
  failed=1
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.7.0
4
+ version: 1.7.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - The Pandorga contributors
@@ -287,6 +287,7 @@ files:
287
287
  - lib/pandorga/commands/publish.rb
288
288
  - lib/pandorga/commands/serve.rb
289
289
  - lib/pandorga/commands/studio_schema.rb
290
+ - lib/pandorga/detail_routes.rb
290
291
  - lib/pandorga/discovery.rb
291
292
  - lib/pandorga/fonts.rb
292
293
  - lib/pandorga/hero_summary.rb
@@ -303,6 +304,7 @@ files:
303
304
  - lib/pandorga/jekyll/plugins/content_validators_hook.rb
304
305
  - lib/pandorga/jekyll/plugins/content_writing_index.rb
305
306
  - lib/pandorga/jekyll/plugins/content_writing_paths.rb
307
+ - lib/pandorga/jekyll/plugins/detail_documents.rb
306
308
  - lib/pandorga/jekyll/plugins/discovery.rb
307
309
  - lib/pandorga/jekyll/plugins/fonts.rb
308
310
  - lib/pandorga/jekyll/plugins/force_utf8_encoding.rb
@@ -466,6 +468,7 @@ files:
466
468
  - scripts/test/test-studio-slug-config.rb
467
469
  - scripts/test/test-template-packages.rb
468
470
  - scripts/test/test-text-excerpt.rb
471
+ - scripts/test/test-theme-contrast.rb
469
472
  - scripts/test/test-unwrap-media-src.mjs
470
473
  - scripts/test/test-unwrap-media-src.rb
471
474
  - scripts/test/test-validate-allowlist.rb