jekyll-theme-zer0 1.27.0 → 1.29.0

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.
Files changed (142) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +812 -13
  3. data/_data/backlog.yml +356 -2
  4. data/_data/consumers.yml +252 -0
  5. data/_data/features.yml +234 -5
  6. data/_data/i18n/fr.yml +103 -0
  7. data/_data/i18n/manifest.yml +1529 -0
  8. data/_data/ingredient_densities.yml +122 -0
  9. data/_data/navigation/docs.yml +2 -0
  10. data/_data/navigation/main.yml +22 -0
  11. data/_data/recipe_courses.yml +64 -0
  12. data/_data/series.yml +19 -0
  13. data/_data/theme-manifest.yml +387 -277
  14. data/_data/ui-text.yml +8 -0
  15. data/_includes/README.md +36 -3
  16. data/_includes/analytics/google-tag-manager-body.html +11 -2
  17. data/_includes/analytics/google-tag-manager-head.html +16 -6
  18. data/_includes/analytics/posthog.html +33 -4
  19. data/_includes/components/abc-letter.html +43 -0
  20. data/_includes/components/background-customizer.html +2 -2
  21. data/_includes/components/book-card.html +42 -0
  22. data/_includes/components/book-nav.html +80 -0
  23. data/_includes/components/book-plate.html +31 -0
  24. data/_includes/components/book-toc.html +47 -0
  25. data/_includes/components/bookshelf.html +68 -0
  26. data/_includes/components/card-grid.html +60 -0
  27. data/_includes/components/cookie-consent.html +4 -4
  28. data/_includes/components/data-card.html +95 -0
  29. data/_includes/components/halfmoon.html +5 -1
  30. data/_includes/components/info-section.html +8 -3
  31. data/_includes/components/language-toggle.html +168 -21
  32. data/_includes/components/page-feedback.html +65 -2
  33. data/_includes/components/page-views-init.html +55 -0
  34. data/_includes/components/page-views.html +33 -0
  35. data/_includes/components/recipe-card.html +67 -0
  36. data/_includes/components/recipe-duration.html +50 -0
  37. data/_includes/components/recipe-grams.html +58 -0
  38. data/_includes/components/recipe-index.html +96 -0
  39. data/_includes/components/recipe-ingredients.html +90 -0
  40. data/_includes/components/recipe-meta.html +96 -0
  41. data/_includes/components/recipe-nutrition.html +57 -0
  42. data/_includes/components/recipe-qty.html +73 -0
  43. data/_includes/components/recipe-ratio.html +151 -0
  44. data/_includes/components/recipe-scaler.html +73 -0
  45. data/_includes/components/recipe-steps.html +86 -0
  46. data/_includes/components/recipe-temp.html +45 -0
  47. data/_includes/components/search-modal.html +2 -2
  48. data/_includes/components/shortcuts-modal.html +3 -0
  49. data/_includes/components/theme-controls-bar.html +10 -2
  50. data/_includes/components/theme-customizer.html +8 -2
  51. data/_includes/components/theme-info.html +8 -1
  52. data/_includes/content/jsonld-software.html +22 -3
  53. data/_includes/content/seo.html +9 -3
  54. data/_includes/core/color-mode-init.html +13 -4
  55. data/_includes/core/favicon.html +46 -0
  56. data/_includes/core/footer.html +18 -16
  57. data/_includes/core/head.html +21 -0
  58. data/_includes/core/header.html +7 -4
  59. data/_includes/custom/body-end.html +18 -0
  60. data/_includes/custom/body-start.html +17 -0
  61. data/_includes/custom/footer.html +18 -0
  62. data/_includes/custom/head.html +18 -0
  63. data/_includes/navigation/local-graph.html +28 -2
  64. data/_includes/navigation/nav-tree.html +3 -3
  65. data/_includes/navigation/section-sidebar.html +93 -11
  66. data/_includes/navigation/sidebar-config.html +21 -0
  67. data/_includes/navigation/sidebar-left.html +2 -1
  68. data/_includes/navigation/sidebar-nav.html +4 -0
  69. data/_includes/navigation/sidebar-pagetree.html +150 -0
  70. data/_includes/navigation/sidebar-right.html +2 -1
  71. data/_includes/navigation/unified-drawer.html +8 -2
  72. data/_includes/obsidian/full-graph.html +165 -136
  73. data/_includes/setup/wizard.html +173 -86
  74. data/_includes/stats/stats-metrics.html +2 -2
  75. data/_layouts/404.html +260 -0
  76. data/_layouts/README.md +4 -0
  77. data/_layouts/admin.html +2 -2
  78. data/_layouts/article.html +5 -0
  79. data/_layouts/book-abc.html +106 -0
  80. data/_layouts/book-story.html +91 -0
  81. data/_layouts/book.html +113 -0
  82. data/_layouts/collection.html +17 -5
  83. data/_layouts/cookbook.html +88 -0
  84. data/_layouts/default.html +4 -4
  85. data/_layouts/home.html +5 -2
  86. data/_layouts/landing.html +9 -0
  87. data/_layouts/news.html +155 -31
  88. data/_layouts/recipe.html +274 -0
  89. data/_layouts/root.html +38 -5
  90. data/_layouts/section.html +96 -25
  91. data/_sass/components/_book.scss +423 -0
  92. data/_sass/components/_callout.scss +1 -1
  93. data/_sass/components/_footer.scss +37 -1
  94. data/_sass/components/_page-views.scss +36 -0
  95. data/_sass/components/_recipe.scss +506 -0
  96. data/_sass/components/_setup-wizard.scss +235 -0
  97. data/_sass/components/_ui-enhancements.scss +6 -6
  98. data/_sass/core/_navbar.scss +263 -15
  99. data/_sass/core/_obsidian.scss +286 -6
  100. data/_sass/layouts/_landing.scss +2 -2
  101. data/_sass/theme/_backgrounds.scss +21 -8
  102. data/_sass/tokens/_color.scss +6 -0
  103. data/_sass/tokens/_index.scss +2 -0
  104. data/_sass/tokens/_radius.scss +21 -0
  105. data/_sass/tokens/_typography.scss +4 -0
  106. data/_sass/utilities/_focus.scss +14 -0
  107. data/assets/css/main.scss +4 -0
  108. data/assets/js/auto-hide-nav.js +5 -1
  109. data/assets/js/halfmoon.js +26 -0
  110. data/assets/js/modules/navigation/navbar.js +55 -0
  111. data/assets/js/obsidian-graph.js +702 -264
  112. data/assets/js/obsidian-local-graph.js +161 -54
  113. data/assets/js/page-views.js +372 -0
  114. data/assets/js/recipe-scaler.js +501 -0
  115. data/assets/js/search-modal.js +14 -1
  116. data/assets/js/setup-wizard.js +322 -20
  117. data/scripts/README.md +29 -0
  118. data/scripts/bin/audit-consumer +39 -7
  119. data/scripts/bin/giscus-discussions +213 -14
  120. data/scripts/bin/manifest +66 -16
  121. data/scripts/bin/validate +5 -1
  122. data/scripts/ci/agent_review_result.py +164 -0
  123. data/scripts/ci/test_agent_review_result.py +172 -0
  124. data/scripts/design-system-check.rb +170 -0
  125. data/scripts/install/README.md +47 -6
  126. data/scripts/install/ai/client.sh +302 -93
  127. data/scripts/install/ai/prompts/spec.schema.json +1 -1
  128. data/scripts/install/ai/wizard.sh +10 -5
  129. data/scripts/install/apply.sh +7 -3
  130. data/scripts/install/cli.sh +54 -4
  131. data/scripts/install/config.sh +167 -0
  132. data/scripts/install/doctor.sh +38 -0
  133. data/scripts/install/plan.sh +10 -0
  134. data/scripts/install/spec.sh +15 -7
  135. data/scripts/install/template.sh +4 -0
  136. data/scripts/lib/audit.sh +42 -2
  137. data/scripts/lint-liquid-raw.rb +137 -0
  138. data/scripts/propagate.rb +277 -0
  139. data/scripts/test/lib/run_tests.sh +2 -1
  140. data/scripts/test/lib/test_agent_review_result.sh +27 -0
  141. data/scripts/translate.rb +161 -15
  142. metadata +56 -2
@@ -0,0 +1,277 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # =============================================================================
5
+ # scripts/propagate.rb — theme release fan-out
6
+ # =============================================================================
7
+ #
8
+ # Reads _data/consumers.yml (the downstream registry) and either REPORTS which
9
+ # consumers have drifted behind the current theme version, or DISPATCHES a
10
+ # `theme-release` event to each one so it opens its own bump PR.
11
+ #
12
+ # The problem this solves: a release used to end at RubyGems. Nothing told the
13
+ # five sites built on this theme that a new version existed, so their pins sat
14
+ # where someone last edited them by hand — all of them a full minor behind when
15
+ # 1.27.0 shipped.
16
+ #
17
+ # Dispatch, rather than this repo opening PRs directly, because every consumer
18
+ # gates differently: it-journey runs `make build-ci` + `make content-audit`,
19
+ # bashconsultants builds in Docker and runs its editorial lint, the org hubs
20
+ # re-roll member configs with provision-org-sites.rb. Each repo owns its own
21
+ # validation; this script only announces the release.
22
+ #
23
+ # Usage:
24
+ # ruby scripts/propagate.rb # report drift (default)
25
+ # ruby scripts/propagate.rb --format json # machine-readable report
26
+ # ruby scripts/propagate.rb --strict # exit 1 if any consumer is behind
27
+ # ruby scripts/propagate.rb --dispatch # fire theme-release events
28
+ # ruby scripts/propagate.rb --dispatch --dry-run
29
+ #
30
+ # Auth: uses `gh` when available, else GH_TOKEN / GITHUB_TOKEN / LIFECYCLE_PAT
31
+ # against the REST API. Reporting works unauthenticated for public repos;
32
+ # --dispatch always needs a token with `repo` scope on the target.
33
+ # =============================================================================
34
+
35
+ require 'yaml'
36
+ require 'json'
37
+ require 'net/http'
38
+ require 'uri'
39
+ require 'optparse'
40
+ require 'base64'
41
+
42
+ REPO_ROOT = File.expand_path('..', __dir__)
43
+ REGISTRY = File.join(REPO_ROOT, '_data', 'consumers.yml')
44
+ VERSION_RB = File.join(REPO_ROOT, 'lib', 'jekyll-theme-zer0', 'version.rb')
45
+
46
+ # Version-bearing patterns per pin kind. `theme_name` and `path_gem` carry no
47
+ # version by design — they are tracked so a bump does not miss the file that
48
+ # activates the theme, but they never report drift.
49
+ PIN_PATTERNS = {
50
+ 'remote_theme' => /remote_theme\s*:\s*["']?bamr87\/zer0-mistakes(?:@v?([0-9][^"'\s]*))?/,
51
+ 'hub_registry' => /theme_repo\s*:\s*["']?bamr87\/zer0-mistakes(?:@v?([0-9][^"'\s]*))?/,
52
+ 'gem_constraint' => /gem\s+["']jekyll-theme-zer0["']\s*,\s*["'][^0-9]*([0-9][^"']*)["']/,
53
+ 'theme_name' => nil,
54
+ 'path_gem' => nil
55
+ }.freeze
56
+
57
+ options = {
58
+ format: 'text', strict: false, dispatch: false, dry_run: false, version: nil
59
+ }
60
+
61
+ OptionParser.new do |o|
62
+ o.banner = 'Usage: ruby scripts/propagate.rb [options]'
63
+ o.on('--dispatch', 'Send theme-release events instead of reporting') { options[:dispatch] = true }
64
+ o.on('--dry-run', 'Show what --dispatch would send; send nothing') { options[:dry_run] = true }
65
+ o.on('--strict', 'Exit 1 when a consumer is behind the current version') { options[:strict] = true }
66
+ o.on('--format FMT', %w[text json github], 'Output format (text|json|github)') { |v| options[:format] = v }
67
+ o.on('--version VER', 'Theme version to compare against (default: version.rb)') { |v| options[:version] = v }
68
+ o.on('-h', '--help', 'Show this help') { puts o; exit 0 }
69
+ end.parse!
70
+
71
+ # -----------------------------------------------------------------------------
72
+ def theme_version
73
+ content = File.read(VERSION_RB)
74
+ content[/VERSION\s*=\s*"([^"]+)"/, 1] or abort('could not parse version.rb')
75
+ end
76
+
77
+ def gh_available?
78
+ return @gh_available unless @gh_available.nil?
79
+
80
+ @gh_available = system('command -v gh > /dev/null 2>&1')
81
+ end
82
+
83
+ def token
84
+ ENV['GH_TOKEN'] || ENV['GITHUB_TOKEN'] || ENV['LIFECYCLE_PAT']
85
+ end
86
+
87
+ def http_get(uri, headers = {})
88
+ req = Net::HTTP::Get.new(uri)
89
+ headers.each { |k, v| req[k] = v }
90
+ res = Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: 10, read_timeout: 15) do |http|
91
+ http.request(req)
92
+ end
93
+ res.is_a?(Net::HTTPSuccess) ? res.body : nil
94
+ rescue StandardError
95
+ nil
96
+ end
97
+
98
+ # Fetch one file from a consumer repo. Returns nil (never raises) when the repo,
99
+ # branch, or path is unreachable — an unreachable consumer must degrade to
100
+ # "unknown" in the report, not take the whole fan-out down.
101
+ #
102
+ # Two transports, in order of capability:
103
+ # 1. api.github.com/contents with a token — the only path that reads PRIVATE
104
+ # consumer repos, and the one CI uses.
105
+ # 2. raw.githubusercontent.com unauthenticated — public repos only.
106
+ # Never send the token to raw.githubusercontent.com: a token that is valid for
107
+ # the API but not for raw turns a public 200 into a 404, which silently reports
108
+ # every consumer as unreachable.
109
+ def fetch_file(repo, branch, path)
110
+ if gh_available?
111
+ out = `gh api "repos/#{repo}/contents/#{path}?ref=#{branch}" --jq .content 2>/dev/null`
112
+ return Base64.decode64(out) if $?.success? && !out.strip.empty?
113
+ end
114
+
115
+ if token
116
+ body = http_get(
117
+ URI("https://api.github.com/repos/#{repo}/contents/#{path}?ref=#{branch}"),
118
+ 'Authorization' => "Bearer #{token}",
119
+ 'Accept' => 'application/vnd.github.raw',
120
+ 'X-GitHub-Api-Version' => '2022-11-28'
121
+ )
122
+ return body if body
123
+ end
124
+
125
+ http_get(URI("https://raw.githubusercontent.com/#{repo}/#{branch}/#{path}"))
126
+ end
127
+
128
+ def extract_pin(kind, content)
129
+ pattern = PIN_PATTERNS[kind]
130
+ return { versioned: false, version: nil } if pattern.nil?
131
+ return { versioned: true, version: nil, missing: true } if content.nil?
132
+
133
+ match = content.match(pattern)
134
+ return { versioned: true, version: nil, missing: true } if match.nil?
135
+
136
+ # A matched pin with no captured version is a deliberate floating ref
137
+ # (`remote_theme: bamr87/zer0-mistakes`), not a parse failure.
138
+ { versioned: true, version: match[1] }
139
+ end
140
+
141
+ def compare(pinned, current)
142
+ return :floating if pinned.nil?
143
+
144
+ a = Gem::Version.new(pinned.sub(/\A[v=~> ]+/, ''))
145
+ b = Gem::Version.new(current)
146
+ return :current if a == b
147
+ return :behind if a < b
148
+
149
+ :ahead
150
+ rescue ArgumentError
151
+ :unknown
152
+ end
153
+
154
+ def dispatch(repo, event, version, dry_run)
155
+ payload = {
156
+ event_type: event,
157
+ client_payload: { version: version, theme: 'bamr87/zer0-mistakes', tag: "v#{version}" }
158
+ }
159
+
160
+ if dry_run
161
+ puts " [DRY RUN] POST repos/#{repo}/dispatches #{payload[:client_payload].to_json}"
162
+ return :dry_run
163
+ end
164
+
165
+ unless token
166
+ warn " ✗ #{repo}: no token (GH_TOKEN / GITHUB_TOKEN / LIFECYCLE_PAT) — cannot dispatch"
167
+ return :no_token
168
+ end
169
+
170
+ uri = URI("https://api.github.com/repos/#{repo}/dispatches")
171
+ req = Net::HTTP::Post.new(uri)
172
+ req['Authorization'] = "Bearer #{token}"
173
+ req['Accept'] = 'application/vnd.github+json'
174
+ req['Content-Type'] = 'application/json'
175
+ req.body = payload.to_json
176
+ res = Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: 10, read_timeout: 15) do |http|
177
+ http.request(req)
178
+ end
179
+
180
+ if res.is_a?(Net::HTTPSuccess)
181
+ :dispatched
182
+ else
183
+ warn " ✗ #{repo}: dispatch failed (HTTP #{res.code})"
184
+ :failed
185
+ end
186
+ rescue StandardError => e
187
+ warn " ✗ #{repo}: dispatch error (#{e.class})"
188
+ :failed
189
+ end
190
+
191
+ # -----------------------------------------------------------------------------
192
+ abort("registry not found: #{REGISTRY}") unless File.exist?(REGISTRY)
193
+
194
+ registry = YAML.load_file(REGISTRY)
195
+ defaults = registry['defaults'] || {}
196
+ current = options[:version] || theme_version
197
+ consumers = registry['consumers'] || []
198
+
199
+ results = consumers.map do |consumer|
200
+ repo = consumer['repo']
201
+ branch = consumer['branch'] || defaults['branch'] || 'main'
202
+
203
+ pins = (consumer['pins'] || []).map do |pin|
204
+ kind = pin['kind']
205
+ content = PIN_PATTERNS[kind].nil? ? nil : fetch_file(repo, branch, pin['file'])
206
+ info = extract_pin(kind, content)
207
+ status = if !info[:versioned] then :not_versioned
208
+ elsif info[:missing] then :unreachable
209
+ else compare(info[:version], current)
210
+ end
211
+ { 'file' => pin['file'], 'kind' => kind, 'pinned' => info[:version], 'status' => status.to_s }
212
+ end
213
+
214
+ versioned = pins.reject { |p| %w[not_versioned unreachable].include?(p['status']) }
215
+ overall = if versioned.any? { |p| p['status'] == 'behind' } then :behind
216
+ elsif versioned.any? { |p| p['status'] == 'floating' } then :floating
217
+ elsif versioned.empty? then :unknown
218
+ elsif versioned.all? { |p| p['status'] == 'current' } then :current
219
+ else :mixed
220
+ end
221
+
222
+ {
223
+ 'repo' => repo, 'mode' => consumer['mode'], 'dispatch' => consumer['dispatch'] == true,
224
+ 'status' => overall.to_s, 'pins' => pins
225
+ }
226
+ end
227
+
228
+ behind = results.select { |r| r['status'] == 'behind' || r['status'] == 'mixed' }
229
+
230
+ # -----------------------------------------------------------------------------
231
+ if options[:dispatch]
232
+ event = defaults['dispatch_event'] || 'theme-release'
233
+ puts "Dispatching #{event} v#{current} to #{results.count { |r| r['dispatch'] }} consumer(s)"
234
+ results.each do |r|
235
+ unless r['dispatch']
236
+ puts " – #{r['repo']}: skipped (dispatch: false — #{r['mode']})"
237
+ next
238
+ end
239
+ outcome = dispatch(r['repo'], event, current, options[:dry_run])
240
+ puts " ✓ #{r['repo']}: #{outcome}" if %i[dispatched dry_run].include?(outcome)
241
+ end
242
+ exit 0
243
+ end
244
+
245
+ case options[:format]
246
+ when 'json'
247
+ puts JSON.pretty_generate({ 'theme_version' => current, 'consumers' => results })
248
+ when 'github'
249
+ results.each do |r|
250
+ next unless %w[behind mixed].include?(r['status'])
251
+
252
+ stale = r['pins'].select { |p| p['status'] == 'behind' }
253
+ .map { |p| "#{p['file']} (#{p['pinned']})" }.join(', ')
254
+ puts "::warning::#{r['repo']} is behind v#{current} — #{stale}"
255
+ end
256
+ puts "::notice::#{behind.size}/#{results.size} consumers behind v#{current}"
257
+ else
258
+ puts "Theme version: #{current}"
259
+ puts
260
+ results.each do |r|
261
+ marker = { 'current' => '✓', 'behind' => '✗', 'floating' => '~', 'mixed' => '✗' }[r['status']] || '?'
262
+ puts format('%s %-38s %-22s %s', marker, r['repo'], r['mode'], r['status'])
263
+ r['pins'].each do |p|
264
+ detail = case p['status']
265
+ when 'not_versioned' then 'no version (tracked only)'
266
+ when 'unreachable' then 'unreachable — check repo/branch/path'
267
+ when 'floating' then 'floating on main'
268
+ else p['pinned']
269
+ end
270
+ puts format(' %-24s %-16s %s', p['file'], p['kind'], detail)
271
+ end
272
+ puts
273
+ end
274
+ puts "#{behind.size}/#{results.size} consumer(s) behind v#{current}"
275
+ end
276
+
277
+ exit 1 if options[:strict] && !behind.empty?
@@ -131,7 +131,8 @@ main() {
131
131
  source "$TEST_DIR/test_migrate.sh"
132
132
  source "$TEST_DIR/test_pixelate_images.sh"
133
133
  source "$TEST_DIR/test_content_review.sh"
134
-
134
+ source "$TEST_DIR/test_agent_review_result.sh"
135
+
135
136
  # Summary
136
137
  echo -e "\n${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
137
138
  echo -e "${BLUE}Test Summary${NC}"
@@ -0,0 +1,27 @@
1
+ #!/bin/bash
2
+
3
+ # Bridge for scripts/ci/test_agent_review_result.py (issue #418).
4
+ #
5
+ # The guard that decides whether the Claude content review actually ran lives in
6
+ # scripts/ci/agent_review_result.py, next to the other CI helper
7
+ # (classify_changes.py), and its tests are written in Python beside it. This
8
+ # wrapper is what puts them on the CI path: run_tests.sh sources this file, and
9
+ # ./scripts/bin/test runs run_tests.sh on every PR.
10
+ #
11
+ # scripts/issues/test_verify_close.py is the cautionary example — a real test
12
+ # suite that no runner ever invoked. Do not let this one drift the same way: if
13
+ # the Python tests move, move this line with them.
14
+
15
+ ARR_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
16
+
17
+ print_suite_header "Claude review guard (scripts/ci/agent_review_result.py)"
18
+
19
+ if command -v python3 >/dev/null 2>&1; then
20
+ arr_output="$(python3 "$ARR_ROOT/scripts/ci/test_agent_review_result.py" 2>&1)"
21
+ arr_status=$?
22
+ echo "$arr_output"
23
+ assert_equals "0" "$arr_status" \
24
+ "scripts/ci/test_agent_review_result.py passes (the #418 401-swallow guard)"
25
+ else
26
+ echo "python3 not available — skipping the Claude review guard tests"
27
+ fi
data/scripts/translate.rb CHANGED
@@ -90,6 +90,14 @@ module Zer0Translate
90
90
  permalink redirect_from redirect_to aliases lang
91
91
  translation_of translation_source_url machine_translated translated_from_sha
92
92
  ].freeze
93
+ # Layouts that only work for a document INSIDE a Jekyll collection. The
94
+ # translated tree is deliberately flat — fr/** are plain pages, not collection
95
+ # documents — so Jekyll never sets `page.collection` there and these layouts
96
+ # have nothing to enumerate. `collection` is worse than useless: it sorts
97
+ # site[page.collection], which is nil for a plain page, and a nil sort aborts
98
+ # the whole Jekyll build. Drop the key so the `path: <lang>` front-matter
99
+ # default in _config.yml (layout: default) applies instead.
100
+ COLLECTION_ONLY_LAYOUTS = %w[collection].freeze
93
101
 
94
102
  DEFAULT_CONFIG = {
95
103
  "enabled" => false,
@@ -367,6 +375,42 @@ module Zer0Translate
367
375
  end
368
376
  end
369
377
 
378
+ # Test-only variant of StubProvider that soft-wraps its output, reproducing
379
+ # the one thing a real provider does that no prompt can reliably prevent.
380
+ # Exists so the prose-normalisation pass has something to actually fix —
381
+ # asserting "output is unwrapped" against the plain stub would pass whether
382
+ # or not normalisation ran.
383
+ class WrappingStubProvider
384
+ WRAP_AT = 40
385
+
386
+ def name = "stub-wrap"
387
+
388
+ def translate(segments, target_lang, _context)
389
+ segments.to_h { |key, text| [key, wrap("#{text} [#{target_lang}]")] }
390
+ end
391
+
392
+ private
393
+
394
+ # Greedy wrap on spaces. Never splits a line that has no space past the
395
+ # column (a long URL or placeholder token stays intact).
396
+ def wrap(text)
397
+ out = []
398
+ line = +""
399
+ text.split(" ").each do |word|
400
+ if line.empty?
401
+ line << word
402
+ elsif line.length + 1 + word.length > WRAP_AT
403
+ out << line
404
+ line = +word
405
+ else
406
+ line << " " << word
407
+ end
408
+ end
409
+ out << line unless line.empty?
410
+ out.join("\n")
411
+ end
412
+ end
413
+
370
414
  class ClaudeProvider
371
415
  ENDPOINT = URI("https://api.anthropic.com/v1/messages")
372
416
  API_VERSION = "2023-06-01"
@@ -380,16 +424,26 @@ module Zer0Translate
380
424
  def initialize(model:, max_tokens:)
381
425
  @model = model
382
426
  @max_tokens = max_tokens
383
- @auth = resolve_auth
427
+ @candidates = resolve_auth_candidates
384
428
  raise "No Anthropic credential found. Set CLAUDE_CODE_OAUTH_TOKEN " \
385
- "(from `claude setup-token`), ANTHROPIC_AUTH_TOKEN, or ANTHROPIC_API_KEY." unless @auth
429
+ "(from `claude setup-token`), ANTHROPIC_AUTH_TOKEN, or ANTHROPIC_API_KEY." if @candidates.empty?
430
+
431
+ @auth = @candidates.first
386
432
  end
387
433
 
388
434
  def name = "claude (#{@model})"
389
435
 
390
436
  def translate(segments, target_lang, context)
391
- payload = build_payload(segments, target_lang, context)
392
- body = request_with_retries(payload)
437
+ body = begin
438
+ # Rebuilt on retry, not hoisted: the payload's first system block
439
+ # depends on @auth[:mode], so falling back to a different credential
440
+ # has to re-derive it.
441
+ request_with_retries(build_payload(segments, target_lang, context))
442
+ rescue AuthError => e
443
+ raise ProviderError, e.message unless advance_credential!(e)
444
+
445
+ retry
446
+ end
393
447
  text = (body["content"] || []).select { |b| b["type"] == "text" }
394
448
  .map { |b| b["text"] }.join("\n")
395
449
  parsed = extract_json(text)
@@ -400,16 +454,38 @@ module Zer0Translate
400
454
 
401
455
  class ProviderError < StandardError; end
402
456
 
457
+ # A credential the API refused (401/403), as opposed to any other failure.
458
+ # Separate so `translate` can fall through to the next configured
459
+ # credential rather than failing the run outright.
460
+ class AuthError < ProviderError; end
461
+
403
462
  private
404
463
 
405
- def resolve_auth
406
- if (token = ENV["CLAUDE_CODE_OAUTH_TOKEN"] || ENV["ANTHROPIC_AUTH_TOKEN"])
407
- return { mode: :oauth, token: token } unless token.empty?
408
- end
409
- if (key = ENV["ANTHROPIC_API_KEY"])
410
- return { mode: :api_key, token: key } unless key.empty?
411
- end
412
- nil
464
+ # Every configured credential, in precedence order.
465
+ #
466
+ # Returning the whole list rather than just the first match is the point:
467
+ # the old lookup skipped credentials that were UNSET but had no way to skip
468
+ # one the API refused, so a set-but-revoked CLAUDE_CODE_OAUTH_TOKEN shadowed
469
+ # a working ANTHROPIC_API_KEY and took the whole run down with it.
470
+ def resolve_auth_candidates
471
+ [
472
+ { mode: :oauth, token: ENV["CLAUDE_CODE_OAUTH_TOKEN"], label: "CLAUDE_CODE_OAUTH_TOKEN" },
473
+ { mode: :oauth, token: ENV["ANTHROPIC_AUTH_TOKEN"], label: "ANTHROPIC_AUTH_TOKEN" },
474
+ { mode: :api_key, token: ENV["ANTHROPIC_API_KEY"], label: "ANTHROPIC_API_KEY" },
475
+ ].reject { |c| c[:token].nil? || c[:token].empty? }
476
+ end
477
+
478
+ # Switch to the next configured credential after this one was refused.
479
+ # Sticky by design — @auth stays switched for the rest of the run, so a
480
+ # dead credential costs one rejection in total rather than one per chunk.
481
+ def advance_credential!(error)
482
+ current = @candidates.index { |c| c.equal?(@auth) }
483
+ nxt = @candidates[current + 1]
484
+ return false unless nxt
485
+
486
+ Log.warn "#{@auth[:label]} rejected (#{error.message}); retrying with #{nxt[:label]}"
487
+ @auth = nxt
488
+ true
413
489
  end
414
490
 
415
491
  def system_blocks(target_lang)
@@ -470,7 +546,10 @@ module Zer0Translate
470
546
  body = JSON.parse(response.body)
471
547
  unless code == 200
472
548
  message = body.dig("error", "message") || response.body[0, 300]
473
- raise ProviderError, "Anthropic API #{code}: #{message}"
549
+ # 401/403 is the credential, not the request — surface it as an
550
+ # AuthError so the caller can try the next configured one.
551
+ klass = [401, 403].include?(code) ? AuthError : ProviderError
552
+ raise klass, "Anthropic API #{code}: #{message}"
474
553
  end
475
554
  body
476
555
  rescue RetryableError
@@ -648,13 +727,34 @@ module Zer0Translate
648
727
  pages[url] ||= {}
649
728
  end
650
729
 
730
+ # Writes the manifest only when the mapping itself changed.
731
+ #
732
+ # `updated_at` used to be stamped on every save, which meant a run that
733
+ # translated NOTHING still rewrote the file. That is not hypothetical: with
734
+ # the API credential rejected, every page failed and the run still produced
735
+ # a one-line `updated_at` diff — enough for translate.yml to open a PR that
736
+ # reads like a routine translation refresh and contains no translations.
737
+ # Serializing with the PREVIOUS timestamp first and comparing against disk
738
+ # keeps that run a no-op, while any real change (a translated page, a prune)
739
+ # still stamps and writes.
651
740
  def save(source_lang, languages)
741
+ previous = @data["updated_at"]
652
742
  @data["version"] = 1
653
743
  @data["source_lang"] = source_lang
654
744
  @data["languages"] = languages
655
- @data["updated_at"] = Time.now.utc.iso8601
656
745
  @data["pages"] = pages.sort.to_h
746
+
747
+ @data["updated_at"] = previous
748
+ return if File.file?(@path) && File.read(@path, encoding: "bom|utf-8") == render
749
+
750
+ @data["updated_at"] = Time.now.utc.iso8601
657
751
  FileUtils.mkdir_p(File.dirname(@path))
752
+ File.write(@path, render)
753
+ end
754
+
755
+ private
756
+
757
+ def render
658
758
  header = <<~HEADER
659
759
  # ================================================================
660
760
  # GENERATED FILE — do not edit by hand.
@@ -663,7 +763,7 @@ module Zer0Translate
663
763
  # _includes/components/language-toggle.html and _includes/core/hreflang.html.
664
764
  # ================================================================
665
765
  HEADER
666
- File.write(@path, header + @data.to_yaml.sub(/\A---\n/, ""))
766
+ header + @data.to_yaml.sub(/\A---\n/, "")
667
767
  end
668
768
  end
669
769
 
@@ -762,6 +862,9 @@ module Zer0Translate
762
862
  @url_builder = UrlBuilder.new(@site_config)
763
863
  @manifest = Manifest.new(@root)
764
864
  @stats = Hash.new(0)
865
+ # Absolute paths of every markdown file this run wrote, for the
866
+ # prose-normalisation pass in `run`.
867
+ @written_pages = []
765
868
  end
766
869
 
767
870
  def run
@@ -790,6 +893,7 @@ module Zer0Translate
790
893
 
791
894
  translator = build_translator
792
895
  execute(plan, translator)
896
+ normalize_prose(@written_pages)
793
897
  prune(sources, languages)
794
898
  @manifest.save(source_lang, languages)
795
899
  summary
@@ -922,6 +1026,7 @@ module Zer0Translate
922
1026
  provider =
923
1027
  case provider_name
924
1028
  when "stub" then StubProvider.new
1029
+ when "stub-wrap" then WrappingStubProvider.new
925
1030
  when "claude"
926
1031
  ClaudeProvider.new(
927
1032
  model: @options[:model] || ENV["TRANSLATE_MODEL"] || @config["model"],
@@ -949,6 +1054,45 @@ module Zer0Translate
949
1054
  end
950
1055
  end
951
1056
 
1057
+ # Generated pages must satisfy the repo's one-paragraph-per-line rule, which
1058
+ # CI enforces via .github/workflows/markdown-oneline.yml. The provider is
1059
+ # free to soft-wrap a translated paragraph across several lines — nothing in
1060
+ # the prompt can guarantee otherwise — so normalise deterministically after
1061
+ # the fact rather than hoping the model complies.
1062
+ #
1063
+ # tools/unwrap-prose.py is the single source of truth for the rule (it is
1064
+ # what CI runs), so shell out to it instead of reimplementing the classifier
1065
+ # here and letting the two drift.
1066
+ #
1067
+ # Best-effort by design: a missing python3 should not fail an otherwise good
1068
+ # translation run. The Translate workflow runs the same tool as a guaranteed
1069
+ # backstop before committing, so the only cost of skipping here is a local
1070
+ # run leaving wrapped prose for that step to fix.
1071
+ def normalize_prose(paths)
1072
+ paths = paths.select { |p| File.file?(p) }
1073
+ return if paths.empty?
1074
+
1075
+ # Resolve the tool relative to THIS script, not to --root. translate.rb
1076
+ # and tools/ ship together; --root points at the content tree being
1077
+ # translated, which is a different thing (and is a throwaway sandbox
1078
+ # under test).
1079
+ tool = File.expand_path("../tools/unwrap-prose.py", __dir__)
1080
+ tool = File.join(@root, "tools", "unwrap-prose.py") unless File.file?(tool)
1081
+ unless File.file?(tool)
1082
+ Log.warn "tools/unwrap-prose.py not found — skipping prose normalisation"
1083
+ return
1084
+ end
1085
+
1086
+ ok = system("python3", tool, "--write", *paths,
1087
+ out: File::NULL, err: File::NULL)
1088
+ if ok
1089
+ Log.info "Normalised prose in #{paths.size} generated page(s)"
1090
+ else
1091
+ Log.warn "prose normalisation skipped (python3 unavailable or tool failed); " \
1092
+ "run: python3 tools/unwrap-prose.py --write"
1093
+ end
1094
+ end
1095
+
952
1096
  def translate_page(job, translator)
953
1097
  file = job.source
954
1098
  segmenter = Segmenter.new(file.body)
@@ -993,6 +1137,7 @@ module Zer0Translate
993
1137
  key = "fm:#{field}"
994
1138
  fm[field] = fm_masker.unmask(translated[key]) if translated.key?(key)
995
1139
  end
1140
+ fm.delete("layout") if COLLECTION_ONLY_LAYOUTS.include?(fm["layout"].to_s)
996
1141
  fm["lang"] = job.lang
997
1142
  fm["permalink"] = "/#{job.lang}#{job.url}"
998
1143
  fm["translation_of"] = file.rel_path
@@ -1007,6 +1152,7 @@ module Zer0Translate
1007
1152
  abs = File.join(@root, out_rel)
1008
1153
  FileUtils.mkdir_p(File.dirname(abs))
1009
1154
  File.write(abs, "#{fm.to_yaml}---\n\n#{body_out.sub(/\A\n+/, '')}")
1155
+ @written_pages << abs
1010
1156
  end
1011
1157
 
1012
1158
  def translate_ui_text(job, translator)