pandorga 1.7.1 → 1.7.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: aa9e02cc789f512401bb4e9bdbc28c732dff0f3849bbf055d0cd546678cbdd1d
4
- data.tar.gz: 8a17699ce008fa34e5114f9b4e8e643d0d754f7620ed3b0357aeb2cc9a4c201e
3
+ metadata.gz: c5c04a8266084060b4aa4810ef57d5d9c25a29302ebd8d946183ba2b95a2daeb
4
+ data.tar.gz: b0aceffe59d1febefc9a308db429f7da42856463b1b7143bdb23f125729de192
5
5
  SHA512:
6
- metadata.gz: 7c6e5413b160b64e546f4aa7c80f1e45bb8c5716336593984e4f1f14e642ed444adba3dc5261d08d31257367060dab631777f744f6126c38df1c264e7259d139
7
- data.tar.gz: 21588e084fe1f417a4f9fa55e532c8ebca01930ea95ad740ecafece15578df4c237461e0213a9328526b982847dcb4bff07d47f1a182a50a36a9fb5348f9cb74
6
+ metadata.gz: 22939efa0329adbf9cc11e0ba771a9fd11d0e154777df7918a1a7bbf821295f3592f4ca1233e43448578deb8a9c69955696f3df282c9f435389e371c69815c1d
7
+ data.tar.gz: 70590f4574d43b101ce9e4148d53df33fc1f6f6e2225df5368d0b11a636d425b1300a10d150038c8d2494e004aaa78f7efd495d56a5cadadee1f671f6ae58158
data/CHANGELOG.md CHANGED
@@ -1,5 +1,42 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.7.3] — 2026-10-12
4
+
5
+ ### Fixed
6
+
7
+ - A detail URL is the listing shell again. The build does not write a
8
+ static page per article, post, project, or product, and those URLs
9
+ are not in the sitemap. The published `_redirects` keeps the splat
10
+ 200 rewrites onto the listing shells, and adds any line the source
11
+ file is missing. The source file is unchanged. The client sets the
12
+ canonical link and `og:url` when it renders the entry.
13
+
14
+ No `pandorga:` config keys were added or renamed. Font roles and the
15
+ ledger fallbacks are unchanged.
16
+
17
+ ## [1.7.2] — 2026-10-11
18
+
19
+ ### Fixed
20
+
21
+ - A published article, post, project, or product is a static page at its
22
+ public URL. The sitemap lists that URL. The static canonical and
23
+ `og:url` are the detail path, and `data-listing-path` stays the
24
+ listing. Articles emit `Article` JSON-LD. Posts emit `BlogPosting`.
25
+ - The build writes `_redirects` into the output and drops the splat 200
26
+ lines that sent those URLs back to the listing shell. The source file
27
+ is unchanged. Publish the built file.
28
+ - A listing page's static `<h1>` is the title from
29
+ `content/pages/headers.yml`, or the page title. It is not the word
30
+ Loading.
31
+ - The top bar is a `<header>`. The section links are one
32
+ `<nav aria-label="Sections">` inside it.
33
+ - Dark theme text on the tertiary container is `#ebe7c3` on `#6a684f`
34
+ (4.52:1). The container colour is unchanged.
35
+ - The hero portrait gate crops the runtime through `pickHeroPortrait`.
36
+
37
+ No `pandorga:` config keys were added or renamed. Font roles and the
38
+ ledger fallbacks are unchanged.
39
+
3
40
  ## [1.7.1] — 2026-10-11
4
41
 
5
42
  ### Fixed
@@ -13,7 +13,8 @@ colors:
13
13
  tertiary_container: "#6a684f"
14
14
  on_primary_container: "#c0d8e6"
15
15
  on_secondary_container: "#b5d9c2"
16
- on_tertiary_container: "#e8e4c0"
16
+ # #e8e4c0 on #6a684f was 4.40:1. Three steps lighter clears 4.5:1.
17
+ on_tertiary_container: "#ebe7c3"
17
18
  chart_project: "#98c8ac"
18
19
 
19
20
  neutrals:
@@ -515,6 +515,17 @@
515
515
  el.setAttribute('content', content);
516
516
  }
517
517
 
518
+ function setCanonicalLink(url) {
519
+ if (!url) return;
520
+ var el = document.head.querySelector('link[rel="canonical"]');
521
+ if (!el) {
522
+ el = document.createElement('link');
523
+ el.setAttribute('rel', 'canonical');
524
+ document.head.appendChild(el);
525
+ }
526
+ el.setAttribute('href', url);
527
+ }
528
+
518
529
  function applyDocumentMeta(meta) {
519
530
  meta = meta || {};
520
531
  var siteSuffix = (window.ContentRuntimeConfig && window.ContentRuntimeConfig.siteSuffix) || '';
@@ -523,7 +534,11 @@
523
534
  if (meta.description) setMetaTag('description', meta.description);
524
535
  setMetaTag('og:title', title, 'property');
525
536
  if (meta.description) setMetaTag('og:description', meta.description, 'property');
526
- if (meta.url) setMetaTag('og:url', absoluteUrl(meta.url), 'property');
537
+ if (meta.url) {
538
+ var pageUrl = absoluteUrl(meta.url);
539
+ setMetaTag('og:url', pageUrl, 'property');
540
+ setCanonicalLink(pageUrl);
541
+ }
527
542
 
528
543
  var defaultOgImage = '/assets/images/og-default-dark.png';
529
544
  var imagePath = meta.image || defaultOgImage;
@@ -264,7 +264,7 @@
264
264
  var standfirstEl = root.querySelector('[data-content-header-standfirst]');
265
265
  return loadPageHeaders().then(function (headers) {
266
266
  var data = headers[pageKey];
267
- if (titleEl) {
267
+ if (titleEl && !titleEl.hasAttribute('data-content-static')) {
268
268
  var title = data && data.title;
269
269
  if (title) {
270
270
  titleEl.innerHTML = stripSingleParagraph(parseInlineMarkdown(String(title)));
@@ -299,7 +299,7 @@
299
299
  }
300
300
  }).catch(function (error) {
301
301
  console.error(error);
302
- if (titleEl) {
302
+ if (titleEl && !titleEl.hasAttribute('data-content-static')) {
303
303
  titleEl.textContent = 'Page header could not be loaded right now.';
304
304
  }
305
305
  });
@@ -1,4 +1,6 @@
1
1
  {% assign _header_key = include.key | default: page.key %}
2
+ {% assign _header_record = site.data.page_headers[_header_key] %}
3
+ {% assign _header_title = _header_record.title | default: page.title | default: _header_key %}
2
4
 
3
5
  <header class="{{ include.header_class | default: 'mb-10 md:mb-12' }}" data-content-page-header="{{ _header_key }}">
4
6
  <div class="page-header-row{% if include.listing_filter %} page-header-row--with-filter{% endif %}">
@@ -6,7 +8,7 @@
6
8
  <span data-content-header-eyebrow class="{{ include.eyebrow_class | default: 'font-label text-sm text-secondary block mb-4 hidden' }}" hidden></span>
7
9
 
8
10
  <h1 class="page-header-title font-title{% if include.title_class %} {{ include.title_class }}{% endif %}">
9
- <span data-content-header-title class="italic">Loading...</span>
11
+ <span data-content-header-title class="italic"{% if _header_title != blank %} data-content-static{% endif %}>{% if _header_title contains "*" %}{% assign _header_bits = _header_title | split: "*" %}{% for _header_bit in _header_bits %}{% assign _header_em = forloop.index0 | modulo: 2 %}{% if _header_em == 1 %}<em>{{ _header_bit | escape }}</em>{% else %}{{ _header_bit | escape }}{% endif %}{% endfor %}{% else %}{{ _header_title | escape }}{% endif %}</span>
10
12
  </h1>
11
13
 
12
14
  <span data-content-header-tags class="{{ include.tags_class | default: 'page-header-tags font-label text-tertiary mt-4 block hidden' }}" hidden></span>
@@ -1,6 +1,7 @@
1
1
  {%- comment -%}
2
- WebSite and Person from identity. No new required keys: name is already
3
- required, and url is the existing optional canonical origin.
2
+ WebSite and Person from identity. Detail Article and BlogPosting
3
+ JSON-LD is not written into the shell. No new required keys: name is
4
+ already required, and url is the existing optional canonical origin.
4
5
  {%- endcomment -%}
5
6
  {%- assign ld_name = site.pandorga.identity.name | default: site.author | default: site.title -%}
6
7
  {%- assign ld_url = site.pandorga.identity.url | default: site.url -%}
data/_layouts/shell.html CHANGED
@@ -211,8 +211,8 @@
211
211
 
212
212
  {% assign _all_pages = site.pages %}
213
213
 
214
- <!-- TopNavBar -->
215
- <nav class="sticky top-0 z-50 bg-surface">
214
+ <!-- TopNavBar. The bar is the banner landmark. The section links are the only nav inside it. -->
215
+ <header class="sticky top-0 z-50 bg-surface">
216
216
  <div class="flex justify-between items-center w-full px-6 sm:px-8 md:px-10 pt-5 pb-3.5 leading-none max-w-7xl mx-auto">
217
217
  <div class="flex items-center gap-3 sm:gap-4 min-w-0">
218
218
  <a href="/" aria-label="{% if _home_label != blank %}{{ _home_label }} – Home{% else %}Home{% endif %}" class="shrink-0 inline-flex items-center gap-3 site-wordmark-link">
@@ -279,7 +279,7 @@
279
279
  </div>
280
280
  <!-- Tonal separator — "No-Line" design rule: background-color shift instead of a CSS border -->
281
281
  <div class="bg-surface-container-low h-[1px]"></div>
282
- </nav>
282
+ </header>
283
283
  <script>
284
284
  (function () {
285
285
  var wrap = document.getElementById('site-menu');
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pandorga
4
+ # Splat 200 lines that serve every detail URL from one listing shell.
5
+ # Cloudflare follows `_redirects` for the request, and the shell loads
6
+ # the entry from the content runtime. The source file is left alone;
7
+ # a missing line is added only to the published copy.
8
+ module DetailRoutes
9
+ SHELL_REWRITES = [
10
+ ["/articles/:slug", "/pages/articles/"],
11
+ ["/posts/:slug", "/pages/blog/"],
12
+ ["/blog/:slug", "/pages/blog/"],
13
+ ["/projects/:slug", "/pages/projects/"],
14
+ ["/products/:slug", "/pages/cv/"]
15
+ ].freeze
16
+
17
+ module_function
18
+
19
+ def ensure_shell_rewrites(text)
20
+ body = text.to_s
21
+ missing = SHELL_REWRITES.reject { |prefix, _target| rewrite_present?(body, prefix) }
22
+ return body if missing.empty?
23
+
24
+ addition = missing.map { |prefix, target| "#{prefix} #{target} 200" }.join("\n")
25
+ stripped = body.rstrip
26
+ stripped.empty? ? "#{addition}\n" : "#{stripped}\n#{addition}\n"
27
+ end
28
+
29
+ def rewrite_present?(text, prefix)
30
+ text.to_s.lines.any? do |line|
31
+ stripped = line.strip
32
+ stripped.match?(%r{\A#{Regexp.escape(prefix)}/?\s+\S+\s+200\z})
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "jekyll"
4
+
5
+ require_relative "../../detail_routes"
6
+
7
+ # Jekyll skips underscore files, so a source `_redirects` never reaches
8
+ # the output unless the site lists it under `include`. The published
9
+ # file is the one Cloudflare reads. Detail URLs stay on the listing
10
+ # shell, so the splat 200 lines have to be in that file. The source
11
+ # file is not edited.
12
+ Jekyll::Hooks.register :site, :post_write do |site|
13
+ dest = File.join(site.dest, "_redirects")
14
+ source = File.join(site.source, "_redirects")
15
+ text = if File.file?(source)
16
+ File.read(source)
17
+ elsif File.file?(dest)
18
+ File.read(dest)
19
+ else
20
+ ""
21
+ end
22
+ updated = Pandorga::DetailRoutes.ensure_shell_rewrites(text)
23
+ next if File.file?(dest) && File.read(dest) == updated
24
+
25
+ File.write(dest, updated)
26
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pandorga
4
- VERSION = "1.7.1"
4
+ VERSION = "1.7.3"
5
5
  end
@@ -6,6 +6,7 @@
6
6
  require "pathname"
7
7
 
8
8
  require_relative "../../lib/pandorga/discovery"
9
+ require_relative "../../lib/pandorga/detail_routes"
9
10
 
10
11
  ROOT = Pathname.new(__dir__).join("..", "..").expand_path
11
12
 
@@ -47,5 +48,38 @@ image_meta = shell[/<meta name="image"[^>]*>/]
47
48
  fail!("shell missing name=image") unless image_meta
48
49
  fail!("name=image also sets property=og:image") if image_meta.include?("property=")
49
50
  fail!("discovery generator is not loaded from the plugin dir") unless ROOT.join("lib/pandorga/jekyll/plugins/discovery.rb").file?
51
+ fail!("detail pages are still generated") if ROOT.join("lib/pandorga/jekyll/plugins/detail_documents.rb").file?
52
+ fail!("shell redirect hook is missing") unless ROOT.join("lib/pandorga/jekyll/plugins/shell_redirects.rb").file?
53
+
54
+ redirects = <<~TXT
55
+ /legacy /articles/2026-01-01-welcome/ 301
56
+ /posts/:slug/ /pages/blog/ 200
57
+ /keep /pages/articles/ 301
58
+ TXT
59
+ ensured = Pandorga::DetailRoutes.ensure_shell_rewrites(redirects)
60
+ %w[/articles/:slug /posts/:slug /blog/:slug /projects/:slug /products/:slug].each do |prefix|
61
+ present = ensured.lines.any? { |line| line.strip.start_with?("#{prefix} ") || line.strip.start_with?("#{prefix}/ ") }
62
+ fail!("published _redirects missing #{prefix}") unless present
63
+ end
64
+ fail!("a 301 was dropped") unless ensured.include?("/legacy /articles/2026-01-01-welcome/ 301")
65
+ fail!("a listing 301 was dropped") unless ensured.include?("/keep /pages/articles/ 301")
66
+ posts = ensured.lines.select { |line| line.include?("/posts/:slug") }
67
+ fail!("an existing splat was duplicated") unless posts.size == 1
68
+ fail!("an existing splat target was rewritten") unless posts.first.include?("/pages/blog/")
69
+ fail!("ensure is not idempotent") unless Pandorga::DetailRoutes.ensure_shell_rewrites(ensured) == ensured
70
+
71
+ serve = ROOT.join("lib/pandorga/jekyll/plugins/local_detail_rewrites.rb").read
72
+ plugins_serve = ROOT.join("_plugins/local_detail_rewrites.rb").read
73
+ fail!("the two local serve copies diverged") unless serve == plugins_serve
74
+ fail!("local serve skips a detail URL") if serve.include?("static_index?")
75
+ fail!("local serve does not rewrite detail URLs") unless serve.include?("ContentDetailPaths.rewrite_path(req.path)")
76
+
77
+ detail = ROOT.join("_includes/content-runtime/50-detail.html").read
78
+ fail!("the client does not set the canonical link") unless detail.include?("setCanonicalLink")
79
+ fail!("the client does not set og:url") unless detail.include?("setMetaTag('og:url'")
80
+
81
+ json_ld = ROOT.join("_includes/theme/json-ld.html").read
82
+ fail!("the build still emits detail JSON-LD") if json_ld.include?("schema_type")
83
+ fail!("shell JSON-LD dropped the person") unless json_ld.include?('"@type": "Person"')
50
84
 
51
85
  puts "PASS test-discovery"
@@ -33,6 +33,10 @@ 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)
38
42
  run!("bundle", "exec", "jekyll", "build", "--quiet", chdir: example)
@@ -65,6 +69,18 @@ end
65
69
  warn "FAIL test-examples-build: full home still hydrates the summary"
66
70
  exit 1
67
71
  end
72
+ {
73
+ "pages/articles/index.html" => "Articles",
74
+ "pages/blog/index.html" => "Rascunhos",
75
+ "pages/projects/index.html" => "Projects"
76
+ }.each do |rel, title|
77
+ listing = example.join("_site", rel).read
78
+ heading = listing[/<h1 class="page-header-title.*?<\/h1>/m].to_s
79
+ unless heading.include?(title) && heading.include?("data-content-static") && !heading.include?("Loading...")
80
+ warn "FAIL test-examples-build: #{rel} static h1 is #{heading.inspect}"
81
+ exit 1
82
+ end
83
+ end
68
84
  else
69
85
  unless html.include?('id="home-cv-summary" class="home-hero-summary font-body" data-content-fragment="cv/summary" data-content-quiet')
70
86
  warn "FAIL test-examples-build: minimal home dropped the summary fallback"
@@ -86,6 +102,47 @@ end
86
102
  warn "FAIL test-examples-build: #{name} robots missing the sitemap line\n#{robots}"
87
103
  exit 1
88
104
  end
105
+ built_redirects_path = example.join("_site/_redirects")
106
+ unless built_redirects_path.file?
107
+ warn "FAIL test-examples-build: #{name} missing built _redirects"
108
+ exit 1
109
+ end
110
+ built_redirects = built_redirects_path.read
111
+ [
112
+ "/articles/:slug /pages/articles/ 200",
113
+ "/posts/:slug /pages/blog/ 200",
114
+ "/blog/:slug /pages/blog/ 200",
115
+ "/projects/:slug /pages/projects/ 200",
116
+ "/products/:slug /pages/cv/ 200"
117
+ ].each do |line|
118
+ unless built_redirects.include?(line)
119
+ warn "FAIL test-examples-build: #{name} built _redirects missing #{line}\n#{built_redirects}"
120
+ exit 1
121
+ end
122
+ end
123
+ if original_redirects && redirects_path.read != original_redirects
124
+ warn "FAIL test-examples-build: #{name} build edited the source _redirects"
125
+ exit 1
126
+ end
127
+ if name == "full"
128
+ %w[
129
+ /articles/2026-01-01-welcome/
130
+ /posts/2026-01-02-note/
131
+ /projects/demo-kit/
132
+ /products/ledger-tool/
133
+ ].each do |path|
134
+ loc = "#{origin}#{path}"
135
+ if sitemap.include?("<loc>#{loc}</loc>")
136
+ warn "FAIL test-examples-build: sitemap lists detail #{loc}"
137
+ exit 1
138
+ end
139
+ page = example.join("_site#{path}index.html")
140
+ if page.file?
141
+ warn "FAIL test-examples-build: static detail page #{page}"
142
+ exit 1
143
+ end
144
+ end
145
+ end
89
146
  unless html.include?('application/ld+json') && html.include?('"@type": "Person"')
90
147
  warn "FAIL test-examples-build: #{name} home is missing JSON-LD"
91
148
  exit 1
@@ -99,6 +156,9 @@ end
99
156
  warn "FAIL test-examples-build: #{name} home dropped the title face"
100
157
  exit 1
101
158
  end
159
+ ensure
160
+ redirects_path.write(original_redirects) if original_redirects
161
+ end
102
162
  end
103
163
 
104
164
  puts "PASS test-examples-build"
@@ -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
 
@@ -27,6 +27,12 @@ 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
+
30
36
  # Decorative Material Symbols sit beside a text label. Without aria-hidden
31
37
  # the ligature ("arrow_left_alt", "shuffle", an icon name) is read aloud.
32
38
  ICON_FILES = [
@@ -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.1
4
+ version: 1.7.3
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
@@ -317,6 +318,7 @@ files:
317
318
  - lib/pandorga/jekyll/plugins/lib/text_excerpt.rb
318
319
  - lib/pandorga/jekyll/plugins/lib/writing_entry.rb
319
320
  - lib/pandorga/jekyll/plugins/local_detail_rewrites.rb
321
+ - lib/pandorga/jekyll/plugins/shell_redirects.rb
320
322
  - lib/pandorga/jekyll/plugins/timeline.rb
321
323
  - lib/pandorga/jekyll/plugins/web_manifest.rb
322
324
  - lib/pandorga/jekyll/plugins/writing_slug_limits.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