pandorga 1.7.2 → 1.7.4
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 +40 -0
- data/_includes/content-runtime/50-detail.html +16 -1
- data/_includes/content-runtime/60-fragments.html +1 -1
- data/_includes/theme/json-ld.html +3 -35
- data/_plugins/local_detail_rewrites.rb +1 -10
- data/assets/css/ledger/detail.css +9 -28
- data/assets/css/ledger/footer.css +5 -2
- data/assets/css/ledger/home.css +1 -2
- data/lib/pandorga/commands/new.rb +1 -1
- data/lib/pandorga/detail_routes.rb +24 -17
- data/lib/pandorga/jekyll/plugins/local_detail_rewrites.rb +1 -10
- data/lib/pandorga/jekyll/plugins/shell_redirects.rb +26 -0
- data/lib/pandorga/version.rb +1 -1
- data/scripts/content/band_art.rb +1 -1
- data/scripts/content/export-content-json.rb +1 -1
- data/scripts/test/test-css-structure.rb +5 -0
- data/scripts/test/test-discovery.rb +24 -21
- data/scripts/test/test-examples-build.rb +32 -62
- data/scripts/test/test-home-band-art.rb +1 -1
- data/scripts/test/test-new.rb +40 -0
- data/scripts/test/test-r2-config-consistency.rb +1 -1
- data/scripts/test/test-theme-contrast.rb +21 -1
- metadata +2 -4
- data/lib/pandorga/jekyll/plugins/detail_documents.rb +0 -154
- data/scripts/content/backfill-content-keys.rb +0 -99
- data/scripts/test/test-cloudflare-watch-paths-doc.rb +0 -31
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8935cbf5ca2ee93ea990c5e18d61831877f04f03b3ba569b3d4347dec3387718
|
|
4
|
+
data.tar.gz: 5c9663302fcce8b2ecf4a5197b03ca66f1f0cca0c7eb2ee0b9d9c9ab2d5e320e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ba771e313ba7ecbc5d13473c75454468d22d18f6f2ec1d22ca896d3bf9bec124aa9b32f5efff0dce38c7495a20a422110313910d56808f7b123ce4e07b29e322
|
|
7
|
+
data.tar.gz: b29d2e32efc698af41f0fdcf7b9da605adfbc238a249dd050c157103493b9305e9fb71a355f919e8189a4a43d41b76b25df2a96b446627a3ba6380f6631fc1f5
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,45 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.7.4] — 2026-10-12
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- The footer imprint separator uses `outline` on the panel step.
|
|
8
|
+
`outline-variant` on that panel is 1.80:1 in light (`#b8b8b8` on
|
|
9
|
+
`#f4f4f4`) and 1.93:1 in dark (`#45454c` on `#141418`). `outline`
|
|
10
|
+
on the same panels is 6.08:1 and 5.95:1.
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- Unused selectors are gone. `assets/css/ledger/home.css` drops
|
|
15
|
+
`.home-hero-pandorga-svg` (25 bytes). The hero SVG uses
|
|
16
|
+
`home-hero-art-svg`. `assets/css/ledger/detail.css` drops 916 bytes:
|
|
17
|
+
the author byline suppressor and the `.table-csv` companions. No
|
|
18
|
+
template or runtime emits either class. 941 bytes in all.
|
|
19
|
+
- The one-shot key backfill is gone (`scripts/content/backfill-content-keys.rb`,
|
|
20
|
+
3,432 bytes). Nothing called it.
|
|
21
|
+
- The watch-path test that read `docs/ops/cloudflare.md` is gone
|
|
22
|
+
(1,230 bytes). That path is not in the tree.
|
|
23
|
+
- Deploy notes pin `~> 1.3` and tag `v1.7.4`. The Pages watch paths
|
|
24
|
+
include `*` and exclude `content/*` and `docs/*`.
|
|
25
|
+
|
|
26
|
+
No `pandorga:` config keys were added or renamed. Font roles and the
|
|
27
|
+
ledger fallbacks are unchanged.
|
|
28
|
+
|
|
29
|
+
## [1.7.3] — 2026-10-12
|
|
30
|
+
|
|
31
|
+
### Fixed
|
|
32
|
+
|
|
33
|
+
- A detail URL is the listing shell again. The build does not write a
|
|
34
|
+
static page per article, post, project, or product, and those URLs
|
|
35
|
+
are not in the sitemap. The published `_redirects` keeps the splat
|
|
36
|
+
200 rewrites onto the listing shells, and adds any line the source
|
|
37
|
+
file is missing. The source file is unchanged. The client sets the
|
|
38
|
+
canonical link and `og:url` when it renders the entry.
|
|
39
|
+
|
|
40
|
+
No `pandorga:` config keys were added or renamed. Font roles and the
|
|
41
|
+
ledger fallbacks are unchanged.
|
|
42
|
+
|
|
3
43
|
## [1.7.2] — 2026-10-11
|
|
4
44
|
|
|
5
45
|
### Fixed
|
|
@@ -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)
|
|
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;
|
|
@@ -385,7 +385,7 @@
|
|
|
385
385
|
}
|
|
386
386
|
|
|
387
387
|
/* The home page's reference band opens each panel with a plate drawn from
|
|
388
|
-
* that section's own content by the export (docs/
|
|
388
|
+
* that section's own content by the export (docs/deploy/cloudflare.md).
|
|
389
389
|
* The markup arrives as an SVG document and is injected inline, never as an
|
|
390
390
|
* <img>: every stroke and fill in it is a custom property, and an external
|
|
391
391
|
* document cannot see the palette the page is currently wearing.
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
{%- comment -%}
|
|
2
|
-
WebSite and Person from identity.
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
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.
|
|
6
5
|
{%- endcomment -%}
|
|
7
6
|
{%- assign ld_name = site.pandorga.identity.name | default: site.author | default: site.title -%}
|
|
8
7
|
{%- assign ld_url = site.pandorga.identity.url | default: site.url -%}
|
|
@@ -11,37 +10,6 @@
|
|
|
11
10
|
{%- else -%}
|
|
12
11
|
{%- assign ld_website = "/" | absolute_url -%}
|
|
13
12
|
{%- 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 -%}
|
|
45
13
|
{%- if ld_name != blank -%}
|
|
46
14
|
<script type="application/ld+json">
|
|
47
15
|
{
|
|
@@ -6,14 +6,6 @@ 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
|
-
|
|
17
9
|
# Mirrors Cloudflare _redirects detail rewrites during `jekyll serve` so
|
|
18
10
|
# /posts/:slug/ and /articles/:slug/ work locally without per-slug HTML files.
|
|
19
11
|
# Also handles the local dev re-export endpoint and proxies /api/studio/* to
|
|
@@ -123,8 +115,7 @@ module Jekyll
|
|
|
123
115
|
end
|
|
124
116
|
|
|
125
117
|
target = ContentDetailPaths.rewrite_path(req.path)
|
|
126
|
-
|
|
127
|
-
if target && !Pandorga::DetailRoutes.static_index?(root, req.path)
|
|
118
|
+
if target
|
|
128
119
|
req.instance_variable_set(:@path, target)
|
|
129
120
|
req.instance_variable_set(:@path_info, target)
|
|
130
121
|
end
|
|
@@ -485,11 +485,9 @@ aside:has(> .text-detail-sidebar) {
|
|
|
485
485
|
font-variant-numeric: tabular-nums;
|
|
486
486
|
}
|
|
487
487
|
|
|
488
|
-
/* Projects and products can carry no meta at all
|
|
489
|
-
the byline below is the block's only child. Either way an all-empty meta
|
|
488
|
+
/* Projects and products can carry no meta at all. An all-empty meta
|
|
490
489
|
block must not hang a stub of padding off the bottom of the panel. */
|
|
491
|
-
.text-detail-sidebar .article-meta:has(> div:empty)
|
|
492
|
-
.text-detail-sidebar .article-meta:has(> div > span.text-secondary:not(.font-bold):only-child) {
|
|
490
|
+
.text-detail-sidebar .article-meta:has(> div:empty) {
|
|
493
491
|
display: none;
|
|
494
492
|
}
|
|
495
493
|
|
|
@@ -497,14 +495,6 @@ aside:has(> .text-detail-sidebar) {
|
|
|
497
495
|
gap: var(--space-sm);
|
|
498
496
|
}
|
|
499
497
|
|
|
500
|
-
/* Author byline. Dropped: single-author site. The runtime still emits it, so
|
|
501
|
-
it is suppressed here until `buildTextSidebar` stops rendering it — see the
|
|
502
|
-
runtime-change note in this branch's commit message. The product/company
|
|
503
|
-
line is the same colour but carries `font-bold`, hence the :not(). */
|
|
504
|
-
.text-detail-sidebar .article-meta>div>span.text-secondary:not(.font-bold) {
|
|
505
|
-
display: none;
|
|
506
|
-
}
|
|
507
|
-
|
|
508
498
|
.text-detail-sidebar .article-meta .text-tertiary,
|
|
509
499
|
.text-detail-sidebar .article-meta span.font-bold {
|
|
510
500
|
font-family: var(--font-label);
|
|
@@ -861,8 +851,7 @@ aside:has(> .text-detail-sidebar) {
|
|
|
861
851
|
header, and figures that line up. */
|
|
862
852
|
/* No table surface: the header band and the hover row are the only fills, so
|
|
863
853
|
both read against the page canvas in either palette. */
|
|
864
|
-
.article-content table
|
|
865
|
-
.article-content .table-csv {
|
|
854
|
+
.article-content table {
|
|
866
855
|
margin: var(--space-xl) 0;
|
|
867
856
|
font-size: var(--type-dek);
|
|
868
857
|
font-variant-numeric: tabular-nums;
|
|
@@ -870,9 +859,7 @@ aside:has(> .text-detail-sidebar) {
|
|
|
870
859
|
}
|
|
871
860
|
|
|
872
861
|
.article-content th,
|
|
873
|
-
.article-content td
|
|
874
|
-
.article-content .table-csv th,
|
|
875
|
-
.article-content .table-csv td {
|
|
862
|
+
.article-content td {
|
|
876
863
|
border: 0;
|
|
877
864
|
border-bottom: 1px solid var(--border-gentle-subtle);
|
|
878
865
|
padding: var(--space-sm) var(--space-md);
|
|
@@ -899,8 +886,7 @@ aside:has(> .text-detail-sidebar) {
|
|
|
899
886
|
font-variant-numeric: tabular-nums;
|
|
900
887
|
}
|
|
901
888
|
|
|
902
|
-
.article-content thead th
|
|
903
|
-
.article-content .table-csv thead th {
|
|
889
|
+
.article-content thead th {
|
|
904
890
|
background: var(--color-surface-container);
|
|
905
891
|
font-family: var(--font-body);
|
|
906
892
|
font-size: var(--type-body);
|
|
@@ -910,13 +896,11 @@ aside:has(> .text-detail-sidebar) {
|
|
|
910
896
|
}
|
|
911
897
|
|
|
912
898
|
/* Zebra striping fights the row rules; the hover row is the reading aid. */
|
|
913
|
-
.article-content tbody tr:nth-child(even)
|
|
914
|
-
.article-content .table-csv tbody tr:nth-child(even) td {
|
|
899
|
+
.article-content tbody tr:nth-child(even) {
|
|
915
900
|
background: transparent;
|
|
916
901
|
}
|
|
917
902
|
|
|
918
|
-
.article-content tbody tr:hover
|
|
919
|
-
.article-content .table-csv tbody tr:hover {
|
|
903
|
+
.article-content tbody tr:hover {
|
|
920
904
|
background: var(--color-surface-container-low);
|
|
921
905
|
}
|
|
922
906
|
|
|
@@ -1131,8 +1115,7 @@ nav.text-page-nav:not(:has(a)) {
|
|
|
1131
1115
|
too wide then scrolls inside the table rather than moving the page. The
|
|
1132
1116
|
runtime emits the table with no wrapper, so the table itself has to be the
|
|
1133
1117
|
scroll container. */
|
|
1134
|
-
.article-content table
|
|
1135
|
-
.article-content .table-csv {
|
|
1118
|
+
.article-content table {
|
|
1136
1119
|
display: block;
|
|
1137
1120
|
width: 100%;
|
|
1138
1121
|
max-width: 100%;
|
|
@@ -1140,9 +1123,7 @@ nav.text-page-nav:not(:has(a)) {
|
|
|
1140
1123
|
}
|
|
1141
1124
|
|
|
1142
1125
|
.article-content th,
|
|
1143
|
-
.article-content td
|
|
1144
|
-
.article-content .table-csv th,
|
|
1145
|
-
.article-content .table-csv td {
|
|
1126
|
+
.article-content td {
|
|
1146
1127
|
padding: var(--space-xs) var(--space-sm);
|
|
1147
1128
|
}
|
|
1148
1129
|
}
|
|
@@ -230,11 +230,14 @@
|
|
|
230
230
|
}
|
|
231
231
|
|
|
232
232
|
/* A middot between items rather than a separator element, so an absent
|
|
233
|
-
license leaves no orphaned punctuation behind.
|
|
233
|
+
license leaves no orphaned punctuation behind. outline-variant is the
|
|
234
|
+
hairline token: #b8b8b8 on #f4f4f4 is 1.80:1, and #45454c on #141418
|
|
235
|
+
is 1.93:1. outline clears 4.5:1 on the same panel (6.08:1 light,
|
|
236
|
+
5.95:1 dark). */
|
|
234
237
|
.site-footer-imprint-item+.site-footer-imprint-item::before {
|
|
235
238
|
content: "·";
|
|
236
239
|
margin-right: var(--space-md);
|
|
237
|
-
color: var(--color-outline
|
|
240
|
+
color: var(--color-outline);
|
|
238
241
|
}
|
|
239
242
|
|
|
240
243
|
.site-footer-imprint-link {
|
data/assets/css/ledger/home.css
CHANGED
|
@@ -24,7 +24,7 @@ module Pandorga
|
|
|
24
24
|
|
|
25
25
|
gem "pandorga", "~> 1.3"
|
|
26
26
|
# Local checkout: gem "pandorga", path: "../pandorga"
|
|
27
|
-
# GitHub tag: gem "pandorga", github: "alexandrelheinen/pandorga", tag: "v1.
|
|
27
|
+
# GitHub tag: gem "pandorga", github: "alexandrelheinen/pandorga", tag: "v1.7.4"
|
|
28
28
|
gem "csv"
|
|
29
29
|
gem "base64"
|
|
30
30
|
RUBY
|
|
@@ -1,29 +1,36 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Pandorga
|
|
4
|
-
#
|
|
5
|
-
# Cloudflare follows `_redirects`
|
|
6
|
-
#
|
|
7
|
-
#
|
|
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
8
|
module DetailRoutes
|
|
9
|
-
|
|
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
|
|
10
16
|
|
|
11
17
|
module_function
|
|
12
18
|
|
|
13
|
-
def
|
|
14
|
-
text.to_s
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
def static_index?(root, path)
|
|
18
|
-
root = root.to_s
|
|
19
|
-
return false if root.empty?
|
|
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?
|
|
20
23
|
|
|
21
|
-
|
|
22
|
-
|
|
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
|
|
24
28
|
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
27
34
|
end
|
|
28
35
|
end
|
|
29
36
|
end
|
|
@@ -6,14 +6,6 @@ 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
|
-
|
|
17
9
|
# Mirrors Cloudflare _redirects detail rewrites during `jekyll serve` so
|
|
18
10
|
# /posts/:slug/ and /articles/:slug/ work locally without per-slug HTML files.
|
|
19
11
|
# Also handles the local dev re-export endpoint and proxies /api/studio/* to
|
|
@@ -123,8 +115,7 @@ module Jekyll
|
|
|
123
115
|
end
|
|
124
116
|
|
|
125
117
|
target = ContentDetailPaths.rewrite_path(req.path)
|
|
126
|
-
|
|
127
|
-
if target && !Pandorga::DetailRoutes.static_index?(root, req.path)
|
|
118
|
+
if target
|
|
128
119
|
req.instance_variable_set(:@path, target)
|
|
129
120
|
req.instance_variable_set(:@path_info, target)
|
|
130
121
|
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
|
data/lib/pandorga/version.rb
CHANGED
data/scripts/content/band_art.rb
CHANGED
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
# because the plates went stale otherwise. Cloudflare Pages excludes `content/*`
|
|
28
28
|
# from its build watch paths, so a Studio save never rebuilds the shell, and art
|
|
29
29
|
# committed into a layout is art that stops counting the day it is written. See
|
|
30
|
-
# docs/
|
|
30
|
+
# docs/deploy/cloudflare.md.
|
|
31
31
|
#
|
|
32
32
|
# The output is SVG whose every stroke and fill is a custom property, because the
|
|
33
33
|
# site has two complete palettes and switches between them on a `data-theme`
|
|
@@ -653,7 +653,7 @@ def export_derived_data(content_root, output_root)
|
|
|
653
653
|
end
|
|
654
654
|
|
|
655
655
|
# The home page's reference band, drawn from the corpus this run just exported
|
|
656
|
-
# rather than from committed markup. See docs/
|
|
656
|
+
# rather than from committed markup. See docs/deploy/cloudflare.md for why
|
|
657
657
|
# it lives here: a Studio save never rebuilds the shell, so art baked into a
|
|
658
658
|
# layout stops counting the day it is written.
|
|
659
659
|
def export_band_art(output_root, writing_index)
|
|
@@ -86,7 +86,12 @@ sheets.each do |path|
|
|
|
86
86
|
home-index-shard--gamma
|
|
87
87
|
home-index-note
|
|
88
88
|
post-content
|
|
89
|
+
home-hero-pandorga-svg
|
|
90
|
+
table-csv
|
|
89
91
|
]
|
|
92
|
+
if cleaned.include?("span.text-secondary:not(.font-bold)")
|
|
93
|
+
errors << "#{relative}: author byline suppressor matches no markup"
|
|
94
|
+
end
|
|
90
95
|
retired.each do |name|
|
|
91
96
|
next unless cleaned.match?(/[.#]#{Regexp.escape(name)}\b/)
|
|
92
97
|
|
|
@@ -3,11 +3,8 @@
|
|
|
3
3
|
|
|
4
4
|
# Portrait sources and discovery documents (PLT-AC-20, PLT-AC-21).
|
|
5
5
|
|
|
6
|
-
require "fileutils"
|
|
7
6
|
require "pathname"
|
|
8
7
|
|
|
9
|
-
require "tmpdir"
|
|
10
|
-
|
|
11
8
|
require_relative "../../lib/pandorga/discovery"
|
|
12
9
|
require_relative "../../lib/pandorga/detail_routes"
|
|
13
10
|
|
|
@@ -51,32 +48,38 @@ image_meta = shell[/<meta name="image"[^>]*>/]
|
|
|
51
48
|
fail!("shell missing name=image") unless image_meta
|
|
52
49
|
fail!("name=image also sets property=og:image") if image_meta.include?("property=")
|
|
53
50
|
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
|
|
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?
|
|
55
53
|
|
|
56
54
|
redirects = <<~TXT
|
|
57
55
|
/legacy /articles/2026-01-01-welcome/ 301
|
|
58
|
-
/articles/:slug /pages/articles/ 200
|
|
59
56
|
/posts/:slug/ /pages/blog/ 200
|
|
60
|
-
/blog/:slug /pages/blog/ 200
|
|
61
|
-
/projects/:slug /pages/projects/ 200
|
|
62
|
-
/products/* /pages/cv/ 200
|
|
63
57
|
/keep /pages/articles/ 301
|
|
64
58
|
TXT
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
fail!("
|
|
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/")
|
|
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
|
|
75
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'")
|
|
76
80
|
|
|
77
81
|
json_ld = ROOT.join("_includes/theme/json-ld.html").read
|
|
78
|
-
fail!("
|
|
79
|
-
fail!("
|
|
80
|
-
fail!("detail schema omits datePublished") unless json_ld.include?("datePublished")
|
|
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"')
|
|
81
84
|
|
|
82
85
|
puts "PASS test-discovery"
|
|
@@ -39,17 +39,6 @@ end
|
|
|
39
39
|
begin
|
|
40
40
|
run!("bundle", "install", "--quiet", chdir: example) if example.join("Gemfile").file?
|
|
41
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
|
|
53
42
|
run!("bundle", "exec", "jekyll", "build", "--quiet", chdir: example)
|
|
54
43
|
|
|
55
44
|
index = example.join("_site/index.html")
|
|
@@ -113,65 +102,46 @@ end
|
|
|
113
102
|
warn "FAIL test-examples-build: #{name} robots missing the sitemap line\n#{robots}"
|
|
114
103
|
exit 1
|
|
115
104
|
end
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
|
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
|
|
151
121
|
end
|
|
152
|
-
|
|
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|
|
|
153
134
|
loc = "#{origin}#{path}"
|
|
154
|
-
|
|
155
|
-
warn "FAIL test-examples-build: sitemap
|
|
135
|
+
if sitemap.include?("<loc>#{loc}</loc>")
|
|
136
|
+
warn "FAIL test-examples-build: sitemap lists detail #{loc}"
|
|
156
137
|
exit 1
|
|
157
138
|
end
|
|
158
139
|
page = example.join("_site#{path}index.html")
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
unless canonical.include?(path)
|
|
162
|
-
warn "FAIL test-examples-build: canonical for #{path} is #{canonical}"
|
|
140
|
+
if page.file?
|
|
141
|
+
warn "FAIL test-examples-build: static detail page #{page}"
|
|
163
142
|
exit 1
|
|
164
143
|
end
|
|
165
144
|
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
145
|
end
|
|
176
146
|
unless html.include?('application/ld+json') && html.include?('"@type": "Person"')
|
|
177
147
|
warn "FAIL test-examples-build: #{name} home is missing JSON-LD"
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env ruby
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
-
# Home band art
|
|
4
|
+
# Home band art. Watch paths: docs/deploy/cloudflare.md.
|
|
5
5
|
#
|
|
6
6
|
# AC-BAND-01 the export writes both plates under data/
|
|
7
7
|
# AC-BAND-03 a second export with no content change reports them unchanged
|
data/scripts/test/test-new.rb
CHANGED
|
@@ -32,6 +32,46 @@ if readme.include?("once 1.1 is on RubyGems")
|
|
|
32
32
|
exit 1
|
|
33
33
|
end
|
|
34
34
|
|
|
35
|
+
version = ROOT.join("lib/pandorga/version.rb").read[/VERSION = "([^"]+)"/, 1]
|
|
36
|
+
unless version
|
|
37
|
+
warn "FAIL test-new: Pandorga::VERSION missing"
|
|
38
|
+
exit 1
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
deploy = ROOT.join("docs/deploy/cloudflare.md").read
|
|
42
|
+
upgrading = ROOT.join("docs/upgrading.md").read
|
|
43
|
+
scaffold = ROOT.join("lib/pandorga/commands/new.rb").read
|
|
44
|
+
[
|
|
45
|
+
[deploy, "docs/deploy/cloudflare.md"],
|
|
46
|
+
[upgrading, "docs/upgrading.md"],
|
|
47
|
+
[scaffold, "lib/pandorga/commands/new.rb"]
|
|
48
|
+
].each do |text, label|
|
|
49
|
+
if text.include?("v1.0.0") || text.include?("v1.3.11") || text.include?("v1.5.0")
|
|
50
|
+
warn "FAIL test-new: #{label} still names a stale release pin"
|
|
51
|
+
exit 1
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
unless deploy.include?('~> 1.3') && deploy.include?(%(tag: "v#{version}"))
|
|
55
|
+
warn "FAIL test-new: deploy doc must pin ~> 1.3 and tag v#{version}"
|
|
56
|
+
exit 1
|
|
57
|
+
end
|
|
58
|
+
unless deploy.include?("content/*") && deploy.include?("docs/*") && deploy.include?("Gemfile.lock")
|
|
59
|
+
warn "FAIL test-new: deploy doc lost the watch-path include and excludes"
|
|
60
|
+
exit 1
|
|
61
|
+
end
|
|
62
|
+
unless deploy.include?("3.4.4") && deploy.include?("SKIP_DEPENDENCY_INSTALL")
|
|
63
|
+
warn "FAIL test-new: deploy doc lost the Pages Ruby pin or SKIP_DEPENDENCY_INSTALL"
|
|
64
|
+
exit 1
|
|
65
|
+
end
|
|
66
|
+
unless upgrading.include?(%(tag: "v#{version}")) && upgrading.include?("pandorga-#{version}.gem")
|
|
67
|
+
warn "FAIL test-new: upgrading examples are not v#{version}"
|
|
68
|
+
exit 1
|
|
69
|
+
end
|
|
70
|
+
unless scaffold.include?(%(tag: "v#{version}"))
|
|
71
|
+
warn "FAIL test-new: scaffold comment is not tag v#{version}"
|
|
72
|
+
exit 1
|
|
73
|
+
end
|
|
74
|
+
|
|
35
75
|
FileUtils.rm_rf(DEST)
|
|
36
76
|
|
|
37
77
|
stdout, stderr, status = run_new(DEST.to_s)
|
|
@@ -36,7 +36,7 @@ end
|
|
|
36
36
|
|
|
37
37
|
if ContentStorageConfig.placeholder?(base)
|
|
38
38
|
puts "SKIP: R2 URL alignment checks (content_api_base_url is still placeholder: #{base})"
|
|
39
|
-
puts " Fill the URL per docs/
|
|
39
|
+
puts " Fill the URL per docs/deploy/cloudflare.md before production cutover."
|
|
40
40
|
exit 0
|
|
41
41
|
end
|
|
42
42
|
|
|
@@ -40,4 +40,24 @@ fail!("dark tertiary container moved") unless background.casecmp("#6a684f").zero
|
|
|
40
40
|
ratio = contrast(foreground, background)
|
|
41
41
|
fail!("#{foreground} on #{background} is #{ratio.round(2)}:1") unless ratio >= 4.5
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
# Footer imprint middot. outline-variant is a hairline and fails as ink
|
|
44
|
+
# on the panel step. The separator uses outline, which clears 4.5:1.
|
|
45
|
+
footer = ROOT.join("assets/css/ledger/footer.css").read
|
|
46
|
+
fail!("footer imprint separator still uses outline-variant") if footer.include?("color: var(--color-outline-variant)")
|
|
47
|
+
fail!("footer imprint separator lost outline") unless footer.include?("color: var(--color-outline)")
|
|
48
|
+
|
|
49
|
+
light = YAML.safe_load(ROOT.join("_data/themes/light.yml").read) || {}
|
|
50
|
+
pairs = {
|
|
51
|
+
"light" => [light.dig("neutrals", "outline"), light.dig("neutrals", "panel_contrast")],
|
|
52
|
+
"dark" => [dark.dig("neutrals", "outline"), dark.dig("neutrals", "panel_contrast")]
|
|
53
|
+
}
|
|
54
|
+
reports = []
|
|
55
|
+
pairs.each do |name, (ink, panel)|
|
|
56
|
+
fail!("#{name} outline or panel missing") if ink.to_s.empty? || panel.to_s.empty?
|
|
57
|
+
|
|
58
|
+
pair_ratio = contrast(ink, panel)
|
|
59
|
+
fail!("#{name} outline #{ink} on panel #{panel} is #{pair_ratio.round(2)}:1") unless pair_ratio >= 4.5
|
|
60
|
+
reports << "#{name} #{ink} on #{panel} = #{pair_ratio.round(2)}:1"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
puts "PASS test-theme-contrast (#{foreground} on #{background} = #{ratio.round(2)}:1; #{reports.join('; ')})"
|
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.4
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- The Pandorga contributors
|
|
@@ -304,7 +304,6 @@ files:
|
|
|
304
304
|
- lib/pandorga/jekyll/plugins/content_validators_hook.rb
|
|
305
305
|
- lib/pandorga/jekyll/plugins/content_writing_index.rb
|
|
306
306
|
- lib/pandorga/jekyll/plugins/content_writing_paths.rb
|
|
307
|
-
- lib/pandorga/jekyll/plugins/detail_documents.rb
|
|
308
307
|
- lib/pandorga/jekyll/plugins/discovery.rb
|
|
309
308
|
- lib/pandorga/jekyll/plugins/fonts.rb
|
|
310
309
|
- lib/pandorga/jekyll/plugins/force_utf8_encoding.rb
|
|
@@ -319,6 +318,7 @@ files:
|
|
|
319
318
|
- lib/pandorga/jekyll/plugins/lib/text_excerpt.rb
|
|
320
319
|
- lib/pandorga/jekyll/plugins/lib/writing_entry.rb
|
|
321
320
|
- lib/pandorga/jekyll/plugins/local_detail_rewrites.rb
|
|
321
|
+
- lib/pandorga/jekyll/plugins/shell_redirects.rb
|
|
322
322
|
- lib/pandorga/jekyll/plugins/timeline.rb
|
|
323
323
|
- lib/pandorga/jekyll/plugins/web_manifest.rb
|
|
324
324
|
- lib/pandorga/jekyll/plugins/writing_slug_limits.rb
|
|
@@ -328,7 +328,6 @@ files:
|
|
|
328
328
|
- lib/pandorga/version.rb
|
|
329
329
|
- scripts/cdn-allowlist.json
|
|
330
330
|
- scripts/check_home_lists.mjs
|
|
331
|
-
- scripts/content/backfill-content-keys.rb
|
|
332
331
|
- scripts/content/band_art.rb
|
|
333
332
|
- scripts/content/export-content-json.rb
|
|
334
333
|
- scripts/content/flatten-ansi-capture.mjs
|
|
@@ -405,7 +404,6 @@ files:
|
|
|
405
404
|
- scripts/test/render-parity/youtube.json
|
|
406
405
|
- scripts/test/test-absent-field-absent-element.rb
|
|
407
406
|
- scripts/test/test-cdn-pins.rb
|
|
408
|
-
- scripts/test/test-cloudflare-watch-paths-doc.rb
|
|
409
407
|
- scripts/test/test-configuration-doc.rb
|
|
410
408
|
- scripts/test/test-content-backend.rb
|
|
411
409
|
- scripts/test/test-content-detail-urls.rb
|
|
@@ -1,154 +0,0 @@
|
|
|
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
|
|
@@ -1,99 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env ruby
|
|
2
|
-
# frozen_string_literal: true
|
|
3
|
-
|
|
4
|
-
require "pathname"
|
|
5
|
-
require "yaml"
|
|
6
|
-
|
|
7
|
-
require_relative "../../_plugins/content_keys"
|
|
8
|
-
require_relative "../../_plugins/content_detail_paths"
|
|
9
|
-
require_relative "../../_plugins/git_chronology"
|
|
10
|
-
|
|
11
|
-
REPO_ROOT = Pathname.new(__dir__).join("..", "..").expand_path
|
|
12
|
-
CONTENT_ROOT = REPO_ROOT.join("content")
|
|
13
|
-
|
|
14
|
-
KEY_OVERRIDES = {
|
|
15
|
-
"content/collections/posts/2026-03-06-horse-auto-follow.md" => "horse-auto-follow-pt",
|
|
16
|
-
"content/collections/posts/2026-03-02-mcmc.md" => "mcmc-pt"
|
|
17
|
-
}.freeze
|
|
18
|
-
|
|
19
|
-
def split_front_matter(text)
|
|
20
|
-
ContentKeys.split_front_matter(text)
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
def write_front_matter(path, front_matter, body)
|
|
24
|
-
yaml = front_matter.to_yaml.sub(/\A---\n/, "").strip
|
|
25
|
-
path.write("---\n#{yaml}\n---\n\n#{body}")
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
def infer_key(entry)
|
|
29
|
-
override = KEY_OVERRIDES[entry["repo_relative_path"]]
|
|
30
|
-
return override if override
|
|
31
|
-
|
|
32
|
-
key = ContentKeys.normalize_key(entry["key"])
|
|
33
|
-
return key unless key.empty?
|
|
34
|
-
|
|
35
|
-
ContentKeys.infer_key_from_filename(entry["path"], entry["type"])
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
def legacy_detail_path(type, legacy_segment)
|
|
39
|
-
case type
|
|
40
|
-
when "article" then ContentDetailPaths.article_detail_path(legacy_segment)
|
|
41
|
-
when "post" then ContentDetailPaths.post_detail_path(legacy_segment)
|
|
42
|
-
when "project" then ContentDetailPaths.project_detail_path(legacy_segment)
|
|
43
|
-
when "product" then ContentDetailPaths.product_detail_path(legacy_segment)
|
|
44
|
-
else
|
|
45
|
-
raise "unknown type #{type}"
|
|
46
|
-
end
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
def migrate_redirect_fields!(front_matter)
|
|
50
|
-
redirects = ContentKeys.normalize_redirects(front_matter)
|
|
51
|
-
front_matter.delete("redirect_from")
|
|
52
|
-
front_matter["redirects"] = redirects unless redirects.empty?
|
|
53
|
-
front_matter
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
def ensure_legacy_redirect!(front_matter, type, legacy_segment, canonical_path)
|
|
57
|
-
redirects = ContentKeys.normalize_redirects(front_matter)
|
|
58
|
-
legacy = legacy_detail_path(type, legacy_segment).chomp("/")
|
|
59
|
-
canonical = canonical_path.chomp("/")
|
|
60
|
-
redirects << legacy unless legacy == canonical
|
|
61
|
-
redirects = redirects.map { |path| ContentKeys.normalize_redirect_path(path) }.compact.uniq
|
|
62
|
-
front_matter["redirects"] = redirects unless redirects.empty?
|
|
63
|
-
front_matter.delete("redirects") if redirects.empty?
|
|
64
|
-
front_matter
|
|
65
|
-
end
|
|
66
|
-
|
|
67
|
-
updated = 0
|
|
68
|
-
ContentKeys::CONTENT_PATHS.each do |type, relative_dir|
|
|
69
|
-
directory = CONTENT_ROOT.join(relative_dir.sub(%r{\Acontent/}, ""))
|
|
70
|
-
next unless directory.directory?
|
|
71
|
-
|
|
72
|
-
Dir.glob(directory.join("*.{md,markdown}")).sort.each do |absolute_path|
|
|
73
|
-
entry = ContentKeys.load_entry(absolute_path, type, repo_root: REPO_ROOT)
|
|
74
|
-
text = File.read(absolute_path).sub(/\A\xEF\xBB\xBF/, "")
|
|
75
|
-
front_matter, body = split_front_matter(text)
|
|
76
|
-
original_yaml = front_matter.to_yaml
|
|
77
|
-
|
|
78
|
-
key = infer_key(entry)
|
|
79
|
-
front_matter["key"] = key
|
|
80
|
-
migrate_redirect_fields!(front_matter)
|
|
81
|
-
|
|
82
|
-
if %w[article post].include?(type)
|
|
83
|
-
GitChronology.apply!(front_matter, entry["repo_relative_path"], type)
|
|
84
|
-
end
|
|
85
|
-
|
|
86
|
-
canonical = ContentKeys.detail_path(type, key, date: front_matter["date"])
|
|
87
|
-
ensure_legacy_redirect!(front_matter, type, entry["legacy_segment"], canonical)
|
|
88
|
-
|
|
89
|
-
next if front_matter.to_yaml == original_yaml
|
|
90
|
-
|
|
91
|
-
write_front_matter(Pathname(absolute_path), front_matter, body.sub(/\A\n+/, ""))
|
|
92
|
-
updated += 1
|
|
93
|
-
puts "updated #{entry['repo_relative_path']} (key=#{key})"
|
|
94
|
-
end
|
|
95
|
-
end
|
|
96
|
-
|
|
97
|
-
ContentKeys.validate!(REPO_ROOT)
|
|
98
|
-
ContentKeys.sync_redirects_file!(REPO_ROOT)
|
|
99
|
-
puts "Backfill complete (#{updated} file(s) updated). Redirects synced to _redirects."
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env ruby
|
|
2
|
-
# frozen_string_literal: true
|
|
3
|
-
|
|
4
|
-
require "pathname"
|
|
5
|
-
|
|
6
|
-
ROOT = Pathname.new(__dir__).join("..", "..").expand_path
|
|
7
|
-
DOC = ROOT.join("docs/ops/cloudflare.md")
|
|
8
|
-
|
|
9
|
-
def check!(condition, message = "condition was false")
|
|
10
|
-
raise "Assertion failed: #{message}" unless condition
|
|
11
|
-
end
|
|
12
|
-
|
|
13
|
-
text = DOC.read
|
|
14
|
-
|
|
15
|
-
puts "Testing Cloudflare watch paths documentation..."
|
|
16
|
-
|
|
17
|
-
check! text.include?("`*`"), "missing include pattern `*`"
|
|
18
|
-
check! text.include?("`content/*`"), "missing exclude pattern content/*"
|
|
19
|
-
check! text.include?("`docs/*`"), "missing exclude pattern docs/*"
|
|
20
|
-
check! text.include?("Settings → Build → Build watch paths"),
|
|
21
|
-
"missing dashboard navigation path"
|
|
22
|
-
check! text.include?("SKIP_DEPENDENCY_INSTALL"),
|
|
23
|
-
"missing SKIP_DEPENDENCY_INSTALL (avoids Pages pip install of study deps)"
|
|
24
|
-
check! text.include?("bundle install") && text.include?("jekyll build"),
|
|
25
|
-
"missing lean Pages build command with explicit bundle install"
|
|
26
|
-
check! text.include?("pandorga install-functions"),
|
|
27
|
-
"missing pandorga install-functions in Pages build command (PLT-0.2)"
|
|
28
|
-
check! text.include?("3.4.4"),
|
|
29
|
-
"missing Pages v3 Ruby 3.4.4 pin (avoids compiling Ruby from source)"
|
|
30
|
-
|
|
31
|
-
puts "Cloudflare watch paths documentation OK."
|