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 +4 -4
- data/CHANGELOG.md +23 -0
- data/_data/themes/dark.yml +2 -1
- data/_includes/content-runtime/60-fragments.html +2 -2
- data/_includes/page/page-header.html +3 -1
- data/_includes/theme/json-ld.html +35 -2
- data/_layouts/shell.html +3 -3
- data/_plugins/local_detail_rewrites.rb +10 -1
- data/lib/pandorga/detail_routes.rb +29 -0
- data/lib/pandorga/jekyll/plugins/detail_documents.rb +154 -0
- data/lib/pandorga/jekyll/plugins/local_detail_rewrites.rb +10 -1
- data/lib/pandorga/version.rb +1 -1
- data/scripts/test/test-discovery.rb +31 -0
- data/scripts/test/test-examples-build.rb +90 -0
- data/scripts/test/test-hero-portraits.mjs +12 -2
- data/scripts/test/test-hero-portraits.rb +12 -4
- data/scripts/test/test-lighthouse-policy.rb +4 -0
- data/scripts/test/test-shell-a11y.rb +6 -0
- data/scripts/test/test-theme-contrast.rb +43 -0
- data/scripts/validate.sh +6 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 13bf0047d336a1cfa896a02c88b8bcb172175c564d922474bf65136b113db106
|
|
4
|
+
data.tar.gz: e146ecadc0250a2315ab17b887a8b2425d9c7ded47cfc0f45c6a3ee1bc0edc6d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/_data/themes/dark.yml
CHANGED
|
@@ -13,7 +13,8 @@ colors:
|
|
|
13
13
|
tertiary_container: "#6a684f"
|
|
14
14
|
on_primary_container: "#c0d8e6"
|
|
15
15
|
on_secondary_container: "#b5d9c2"
|
|
16
|
-
|
|
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">
|
|
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.
|
|
3
|
-
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/pandorga/version.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
`${
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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.
|
|
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
|