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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 13bf0047d336a1cfa896a02c88b8bcb172175c564d922474bf65136b113db106
4
- data.tar.gz: e146ecadc0250a2315ab17b887a8b2425d9c7ded47cfc0f45c6a3ee1bc0edc6d
3
+ metadata.gz: 8935cbf5ca2ee93ea990c5e18d61831877f04f03b3ba569b3d4347dec3387718
4
+ data.tar.gz: 5c9663302fcce8b2ecf4a5197b03ca66f1f0cca0c7eb2ee0b9d9c9ab2d5e320e
5
5
  SHA512:
6
- metadata.gz: e5ccb2966deff68f8c3292f997cd39980024e47eeaab2e756f74d193e03ff6b40fd5f235c51fc30a69277057c31350246451004b25f1086df24a163efd45c777
7
- data.tar.gz: df67d8c8be5fd84ffca3abde105d4b05e6c76030e9e1d45f9e26c6dfdb1722a2ecd82ba55a4b9d699a73f1dacd5bc5999402d1ae7f60f727bee5a8058f7f1c16
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) setMetaTag('og:url', absoluteUrl(meta.url), 'property');
537
+ if (meta.url) {
538
+ var pageUrl = absoluteUrl(meta.url);
539
+ setMetaTag('og:url', pageUrl, 'property');
540
+ setCanonicalLink(pageUrl);
541
+ }
527
542
 
528
543
  var defaultOgImage = '/assets/images/og-default-dark.png';
529
544
  var imagePath = meta.image || defaultOgImage;
@@ -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/features/home-band-art.md).
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. 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.
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
- root = instance_variable_get(:@root)
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, and on articles and posts
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-variant);
240
+ color: var(--color-outline);
238
241
  }
239
242
 
240
243
  .site-footer-imprint-link {
@@ -91,8 +91,7 @@
91
91
  opacity: 0.42;
92
92
  }
93
93
 
94
- .home-hero-art-svg,
95
- .home-hero-pandorga-svg {
94
+ .home-hero-art-svg {
96
95
  width: 100%;
97
96
  height: 100%;
98
97
  display: block;
@@ -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.5.0"
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
- # 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.
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
- SPLAT_LINE = %r{\A/(?:articles|posts|blog|projects|products)/(?::slug|\*)/?\s+\S+\s+200\s*\z}
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 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?
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
- 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
+ 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
- File.file?(File.join(root, relative, "index.html")) ||
26
- File.file?(File.join(root, "#{relative}.html"))
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
- root = instance_variable_get(:@root)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pandorga
4
- VERSION = "1.7.2"
4
+ VERSION = "1.7.4"
5
5
  end
@@ -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/features/home-band-art.md.
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/features/home-band-art.md for why
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 not generated from the plugin dir") unless ROOT.join("lib/pandorga/jekyll/plugins/detail_documents.rb").file?
51
+ fail!("detail pages are still generated") if ROOT.join("lib/pandorga/jekyll/plugins/detail_documents.rb").file?
52
+ fail!("shell redirect hook is missing") unless ROOT.join("lib/pandorga/jekyll/plugins/shell_redirects.rb").file?
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
- 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/")
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!("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")
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
- 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
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
- ["/projects/demo-kit/", "/products/ledger-tool/"].each do |path|
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
- unless sitemap.include?("<loc>#{loc}</loc>")
155
- warn "FAIL test-examples-build: sitemap missing #{loc}\n#{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
- 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}"
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 (docs/features/home-band-art.md).
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
@@ -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/ops/cloudflare.md before production cutover."
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
- puts "PASS test-theme-contrast (#{foreground} on #{background} = #{ratio.round(2)}:1)"
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.2
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."