pandorga 1.7.1 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: aa9e02cc789f512401bb4e9bdbc28c732dff0f3849bbf055d0cd546678cbdd1d
4
- data.tar.gz: 8a17699ce008fa34e5114f9b4e8e643d0d754f7620ed3b0357aeb2cc9a4c201e
3
+ metadata.gz: 13bf0047d336a1cfa896a02c88b8bcb172175c564d922474bf65136b113db106
4
+ data.tar.gz: e146ecadc0250a2315ab17b887a8b2425d9c7ded47cfc0f45c6a3ee1bc0edc6d
5
5
  SHA512:
6
- metadata.gz: 7c6e5413b160b64e546f4aa7c80f1e45bb8c5716336593984e4f1f14e642ed444adba3dc5261d08d31257367060dab631777f744f6126c38df1c264e7259d139
7
- data.tar.gz: 21588e084fe1f417a4f9fa55e532c8ebca01930ea95ad740ecafece15578df4c237461e0213a9328526b982847dcb4bff07d47f1a182a50a36a9fb5348f9cb74
6
+ metadata.gz: e5ccb2966deff68f8c3292f997cd39980024e47eeaab2e756f74d193e03ff6b40fd5f235c51fc30a69277057c31350246451004b25f1086df24a163efd45c777
7
+ data.tar.gz: df67d8c8be5fd84ffca3abde105d4b05e6c76030e9e1d45f9e26c6dfdb1722a2ecd82ba55a4b9d699a73f1dacd5bc5999402d1ae7f60f727bee5a8058f7f1c16
data/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.7.2] — 2026-10-11
4
+
5
+ ### Fixed
6
+
7
+ - A published article, post, project, or product is a static page at its
8
+ public URL. The sitemap lists that URL. The static canonical and
9
+ `og:url` are the detail path, and `data-listing-path` stays the
10
+ listing. Articles emit `Article` JSON-LD. Posts emit `BlogPosting`.
11
+ - The build writes `_redirects` into the output and drops the splat 200
12
+ lines that sent those URLs back to the listing shell. The source file
13
+ is unchanged. Publish the built file.
14
+ - A listing page's static `<h1>` is the title from
15
+ `content/pages/headers.yml`, or the page title. It is not the word
16
+ Loading.
17
+ - The top bar is a `<header>`. The section links are one
18
+ `<nav aria-label="Sections">` inside it.
19
+ - Dark theme text on the tertiary container is `#ebe7c3` on `#6a684f`
20
+ (4.52:1). The container colour is unchanged.
21
+ - The hero portrait gate crops the runtime through `pickHeroPortrait`.
22
+
23
+ No `pandorga:` config keys were added or renamed. Font roles and the
24
+ ledger fallbacks are unchanged.
25
+
3
26
  ## [1.7.1] — 2026-10-11
4
27
 
5
28
  ### 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:
@@ -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,8 @@
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. A detail page with schema_type
3
+ Article or BlogPosting also emits that type. No new required keys:
4
+ name is already required, and url is the existing optional canonical
5
+ origin.
4
6
  {%- endcomment -%}
5
7
  {%- assign ld_name = site.pandorga.identity.name | default: site.author | default: site.title -%}
6
8
  {%- assign ld_url = site.pandorga.identity.url | default: site.url -%}
@@ -9,6 +11,37 @@
9
11
  {%- else -%}
10
12
  {%- assign ld_website = "/" | absolute_url -%}
11
13
  {%- endif -%}
14
+ {%- assign ld_canonical = canonical_path | default: page.canonical_url | default: page.url -%}
15
+ {%- if page.schema_type == "Article" or page.schema_type == "BlogPosting" -%}
16
+ {%- if page.title != blank -%}
17
+ <script type="application/ld+json">
18
+ {
19
+ "@context": "https://schema.org",
20
+ "@type": {{ page.schema_type | jsonify }},
21
+ "headline": {{ page.title | jsonify }},
22
+ "url": {{ ld_canonical | absolute_url | jsonify }}
23
+ {%- if page.description != blank -%},
24
+ "description": {{ page.description | jsonify }}
25
+ {%- endif -%}
26
+ {%- if page.date -%},
27
+ "datePublished": {{ page.date | date_to_xmlschema | jsonify }}
28
+ {%- endif -%}
29
+ {%- if page.last_updated -%},
30
+ "dateModified": {{ page.last_updated | date_to_xmlschema | jsonify }}
31
+ {%- endif -%}
32
+ {%- if page.lang -%},
33
+ "inLanguage": {{ page.lang | jsonify }}
34
+ {%- endif -%}
35
+ {%- if ld_name != blank -%},
36
+ "author": {
37
+ "@type": "Person",
38
+ "name": {{ ld_name | jsonify }}
39
+ }
40
+ {%- endif -%}
41
+ }
42
+ </script>
43
+ {%- endif -%}
44
+ {%- endif -%}
12
45
  {%- if ld_name != blank -%}
13
46
  <script type="application/ld+json">
14
47
  {
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');
@@ -6,6 +6,14 @@ require "net/http"
6
6
  require "uri"
7
7
  require_relative "content_detail_paths"
8
8
 
9
+ # The gem copy and the _plugins copy stay the same file. Either path can
10
+ # resolve the shared helper.
11
+ detail_routes = [
12
+ File.expand_path("../../../detail_routes", __dir__),
13
+ File.expand_path("../lib/pandorga/detail_routes", __dir__)
14
+ ].find { |path| File.file?("#{path}.rb") }
15
+ require detail_routes if detail_routes
16
+
9
17
  # Mirrors Cloudflare _redirects detail rewrites during `jekyll serve` so
10
18
  # /posts/:slug/ and /articles/:slug/ work locally without per-slug HTML files.
11
19
  # Also handles the local dev re-export endpoint and proxies /api/studio/* to
@@ -115,7 +123,8 @@ module Jekyll
115
123
  end
116
124
 
117
125
  target = ContentDetailPaths.rewrite_path(req.path)
118
- if target
126
+ root = instance_variable_get(:@root)
127
+ if target && !Pandorga::DetailRoutes.static_index?(root, req.path)
119
128
  req.instance_variable_set(:@path, target)
120
129
  req.instance_variable_set(:@path_info, target)
121
130
  end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pandorga
4
+ # Detail URLs that used to be 200-rewritten onto a listing shell.
5
+ # Cloudflare follows `_redirects` even when a static file exists, so the
6
+ # built file must drop those splat lines or the generated page is never
7
+ # served. The source file is left alone.
8
+ module DetailRoutes
9
+ SPLAT_LINE = %r{\A/(?:articles|posts|blog|projects|products)/(?::slug|\*)/?\s+\S+\s+200\s*\z}
10
+
11
+ module_function
12
+
13
+ def strip_splat_rewrites(text)
14
+ text.to_s.lines.reject { |line| SPLAT_LINE.match?(line.strip) }.join
15
+ end
16
+
17
+ def static_index?(root, path)
18
+ root = root.to_s
19
+ return false if root.empty?
20
+
21
+ clean = path.to_s.split("?").first.to_s
22
+ relative = clean.sub(%r{\A/}, "").sub(%r{/\z}, "")
23
+ return false if relative.empty? || relative.include?("..")
24
+
25
+ File.file?(File.join(root, relative, "index.html")) ||
26
+ File.file?(File.join(root, "#{relative}.html"))
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "jekyll"
4
+ require "pathname"
5
+
6
+ require_relative "../../detail_routes"
7
+ require_relative "lib/writing_entry"
8
+
9
+ module Pandorga
10
+ module Jekyll
11
+ # One static page per published article, post, project, and product.
12
+ # The listing layout stays, so the client still opens the entry from
13
+ # the URL. `listing_path` stays the listing URL. The permalink is the
14
+ # detail URL, which is what the sitemap and the static canonical use.
15
+ class DetailDocumentsGenerator < ::Jekyll::Generator
16
+ safe true
17
+ priority :low
18
+
19
+ COLLECTION_FOR_TYPE = {
20
+ "article" => "articles",
21
+ "post" => "posts",
22
+ "project" => "projects",
23
+ "product" => "products"
24
+ }.freeze
25
+
26
+ DETAIL_PATTERN = {
27
+ "article" => "/articles/:slug/",
28
+ "post" => "/posts/:slug/",
29
+ "project" => "/projects/:slug/",
30
+ "product" => "/products/:slug/"
31
+ }.freeze
32
+
33
+ SCHEMA = {
34
+ "article" => "Article",
35
+ "post" => "BlogPosting"
36
+ }.freeze
37
+
38
+ def generate(site)
39
+ return if site.config["shell_content_agnostic"]
40
+
41
+ root = Pathname.new(site.source)
42
+ COLLECTION_FOR_TYPE.each do |type, collection|
43
+ listing = listing_page(site, type)
44
+ next unless listing
45
+ next if listing.data["listing_path"].to_s.empty?
46
+
47
+ directory = root.join(ContentKeys::CONTENT_PATHS.fetch(type))
48
+ next unless directory.directory?
49
+
50
+ Dir.glob(directory.join("*.{md,markdown}")).sort.each do |absolute|
51
+ emit(site, listing, type, collection, absolute, root)
52
+ end
53
+ end
54
+ end
55
+
56
+ private
57
+
58
+ def emit(site, listing, type, collection, absolute, root)
59
+ relative = Pathname.new(absolute).relative_path_from(root.join("content"))
60
+ repo_relative = Pathname.new("content").join(relative).to_s
61
+ record = WritingEntry.build(absolute, collection, repo_relative)
62
+ return if record.nil?
63
+
64
+ detail_path = record["url"].to_s
65
+ return if detail_path.empty?
66
+ return if taken?(site, detail_path)
67
+
68
+ front_matter, = ContentKeys.split_front_matter(File.read(absolute))
69
+ dir = detail_path.sub(%r{\A/}, "").sub(%r{/\z}, "")
70
+ page = ::Jekyll::PageWithoutAFile.new(site, site.source, dir, "index.html")
71
+ page.data["layout"] = listing.data["layout"]
72
+ page.data["permalink"] = detail_path
73
+ page.data["canonical_url"] = detail_path
74
+ page.data["listing_path"] = listing.data["listing_path"]
75
+ page.data["collection"] = collection
76
+ page.data["detail"] = listing.data["detail"].to_s
77
+ page.data["detail"] = DETAIL_PATTERN.fetch(type) if page.data["detail"].empty?
78
+ page.data["detail_document"] = true
79
+ page.data["accent"] = listing.data["accent"] if listing.data["accent"]
80
+ title = record["title"].to_s.strip
81
+ title = record["key"].to_s if title.empty?
82
+ page.data["title"] = title unless title.empty?
83
+ description = record["description"].to_s.strip
84
+ description = record["excerpt"].to_s.strip if description.empty?
85
+ page.data["description"] = description unless description.empty?
86
+ page.data["date"] = record["date"] unless record["date"].to_s.empty?
87
+ page.data["last_updated"] = record["last_updated"] unless record["last_updated"].to_s.empty?
88
+ thumbnail = record["thumbnail"].to_s.strip
89
+ page.data["thumbnail"] = thumbnail unless thumbnail.empty?
90
+ lang = front_matter["language"] || front_matter["lang"] || listing.data["lang"]
91
+ page.data["lang"] = lang if lang
92
+ if SCHEMA.key?(type)
93
+ page.data["og_type"] = "article"
94
+ page.data["schema_type"] = SCHEMA.fetch(type)
95
+ end
96
+ page.content = ""
97
+ site.pages << page
98
+ end
99
+
100
+ def taken?(site, detail_path)
101
+ site.pages.any? do |page|
102
+ page.data["permalink"].to_s == detail_path || page.url.to_s == detail_path
103
+ end
104
+ end
105
+
106
+ def listing_page(site, type)
107
+ entry = registry_entry(site, type)
108
+ return nil unless entry
109
+
110
+ site.pages.find do |page|
111
+ page.data["detail_document"] != true && page.data["key"].to_s == entry["key"].to_s
112
+ end
113
+ end
114
+
115
+ def registry_entry(site, type)
116
+ pages = site.data["pandorga_pages"]
117
+ return nil unless pages.is_a?(Array)
118
+
119
+ pages.find { |entry| entry.is_a?(Hash) && registry_match?(entry, type) }
120
+ end
121
+
122
+ def registry_match?(entry, type)
123
+ case type
124
+ when "article"
125
+ entry["template"].to_s == "articles" || entry["collection"].to_s == "articles"
126
+ when "post"
127
+ entry["template"].to_s == "blog" || entry["collection"].to_s == "posts"
128
+ when "project"
129
+ entry["template"].to_s == "portfolio" || entry["collection"].to_s == "projects"
130
+ when "product"
131
+ cols = entry["collections"]
132
+ entry["template"].to_s == "cv" ||
133
+ (cols.is_a?(Hash) && !cols["products"].to_s.empty?)
134
+ else
135
+ false
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end
141
+
142
+ Jekyll::Hooks.register :site, :post_write do |site|
143
+ # Jekyll skips underscore files, so a source `_redirects` never reaches
144
+ # the output unless the site lists it under `include`. The published
145
+ # file has to be the one Cloudflare reads.
146
+ dest = File.join(site.dest, "_redirects")
147
+ source = File.join(site.source, "_redirects")
148
+ File.write(dest, File.read(source)) if !File.file?(dest) && File.file?(source)
149
+ next unless File.file?(dest)
150
+
151
+ original = File.read(dest)
152
+ stripped = Pandorga::DetailRoutes.strip_splat_rewrites(original)
153
+ File.write(dest, stripped) if stripped != original
154
+ end
@@ -6,6 +6,14 @@ require "net/http"
6
6
  require "uri"
7
7
  require_relative "content_detail_paths"
8
8
 
9
+ # The gem copy and the _plugins copy stay the same file. Either path can
10
+ # resolve the shared helper.
11
+ detail_routes = [
12
+ File.expand_path("../../../detail_routes", __dir__),
13
+ File.expand_path("../lib/pandorga/detail_routes", __dir__)
14
+ ].find { |path| File.file?("#{path}.rb") }
15
+ require detail_routes if detail_routes
16
+
9
17
  # Mirrors Cloudflare _redirects detail rewrites during `jekyll serve` so
10
18
  # /posts/:slug/ and /articles/:slug/ work locally without per-slug HTML files.
11
19
  # Also handles the local dev re-export endpoint and proxies /api/studio/* to
@@ -115,7 +123,8 @@ module Jekyll
115
123
  end
116
124
 
117
125
  target = ContentDetailPaths.rewrite_path(req.path)
118
- if target
126
+ root = instance_variable_get(:@root)
127
+ if target && !Pandorga::DetailRoutes.static_index?(root, req.path)
119
128
  req.instance_variable_set(:@path, target)
120
129
  req.instance_variable_set(:@path_info, target)
121
130
  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.2"
5
5
  end
@@ -3,9 +3,13 @@
3
3
 
4
4
  # Portrait sources and discovery documents (PLT-AC-20, PLT-AC-21).
5
5
 
6
+ require "fileutils"
6
7
  require "pathname"
7
8
 
9
+ require "tmpdir"
10
+
8
11
  require_relative "../../lib/pandorga/discovery"
12
+ require_relative "../../lib/pandorga/detail_routes"
9
13
 
10
14
  ROOT = Pathname.new(__dir__).join("..", "..").expand_path
11
15
 
@@ -47,5 +51,32 @@ image_meta = shell[/<meta name="image"[^>]*>/]
47
51
  fail!("shell missing name=image") unless image_meta
48
52
  fail!("name=image also sets property=og:image") if image_meta.include?("property=")
49
53
  fail!("discovery generator is not loaded from the plugin dir") unless ROOT.join("lib/pandorga/jekyll/plugins/discovery.rb").file?
54
+ fail!("detail pages are not generated from the plugin dir") unless ROOT.join("lib/pandorga/jekyll/plugins/detail_documents.rb").file?
55
+
56
+ redirects = <<~TXT
57
+ /legacy /articles/2026-01-01-welcome/ 301
58
+ /articles/:slug /pages/articles/ 200
59
+ /posts/:slug/ /pages/blog/ 200
60
+ /blog/:slug /pages/blog/ 200
61
+ /projects/:slug /pages/projects/ 200
62
+ /products/* /pages/cv/ 200
63
+ /keep /pages/articles/ 301
64
+ TXT
65
+ stripped = Pandorga::DetailRoutes.strip_splat_rewrites(redirects)
66
+ fail!("splat 200 rewrite survived") if stripped.match?(%r{/(?:articles|posts|blog|projects|products)/(?::slug|\*)})
67
+ fail!("a 301 was stripped with the splats") unless stripped.include?("/legacy /articles/2026-01-01-welcome/ 301")
68
+ fail!("a listing 301 was stripped") unless stripped.include?("/keep /pages/articles/ 301")
69
+
70
+ Dir.mktmpdir("detail-static") do |dir|
71
+ FileUtils.mkdir_p(File.join(dir, "articles", "welcome"))
72
+ File.write(File.join(dir, "articles", "welcome", "index.html"), "<p>welcome</p>")
73
+ fail!("a built detail page must not be rewritten") unless Pandorga::DetailRoutes.static_index?(dir, "/articles/welcome/")
74
+ fail!("a missing detail page must still be rewritten") if Pandorga::DetailRoutes.static_index?(dir, "/articles/missing/")
75
+ end
76
+
77
+ json_ld = ROOT.join("_includes/theme/json-ld.html").read
78
+ fail!("detail pages have no Article schema") unless json_ld.include?('page.schema_type == "Article"')
79
+ fail!("detail pages have no BlogPosting schema") unless json_ld.include?('page.schema_type == "BlogPosting"')
80
+ fail!("detail schema omits datePublished") unless json_ld.include?("datePublished")
50
81
 
51
82
  puts "PASS test-discovery"
@@ -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"
@@ -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.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