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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +812 -13
- data/_data/backlog.yml +356 -2
- data/_data/consumers.yml +252 -0
- data/_data/features.yml +234 -5
- data/_data/i18n/fr.yml +103 -0
- data/_data/i18n/manifest.yml +1529 -0
- data/_data/ingredient_densities.yml +122 -0
- data/_data/navigation/docs.yml +2 -0
- data/_data/navigation/main.yml +22 -0
- data/_data/recipe_courses.yml +64 -0
- data/_data/series.yml +19 -0
- data/_data/theme-manifest.yml +387 -277
- data/_data/ui-text.yml +8 -0
- data/_includes/README.md +36 -3
- data/_includes/analytics/google-tag-manager-body.html +11 -2
- data/_includes/analytics/google-tag-manager-head.html +16 -6
- data/_includes/analytics/posthog.html +33 -4
- data/_includes/components/abc-letter.html +43 -0
- data/_includes/components/background-customizer.html +2 -2
- data/_includes/components/book-card.html +42 -0
- data/_includes/components/book-nav.html +80 -0
- data/_includes/components/book-plate.html +31 -0
- data/_includes/components/book-toc.html +47 -0
- data/_includes/components/bookshelf.html +68 -0
- data/_includes/components/card-grid.html +60 -0
- data/_includes/components/cookie-consent.html +4 -4
- data/_includes/components/data-card.html +95 -0
- data/_includes/components/halfmoon.html +5 -1
- data/_includes/components/info-section.html +8 -3
- data/_includes/components/language-toggle.html +168 -21
- data/_includes/components/page-feedback.html +65 -2
- data/_includes/components/page-views-init.html +55 -0
- data/_includes/components/page-views.html +33 -0
- data/_includes/components/recipe-card.html +67 -0
- data/_includes/components/recipe-duration.html +50 -0
- data/_includes/components/recipe-grams.html +58 -0
- data/_includes/components/recipe-index.html +96 -0
- data/_includes/components/recipe-ingredients.html +90 -0
- data/_includes/components/recipe-meta.html +96 -0
- data/_includes/components/recipe-nutrition.html +57 -0
- data/_includes/components/recipe-qty.html +73 -0
- data/_includes/components/recipe-ratio.html +151 -0
- data/_includes/components/recipe-scaler.html +73 -0
- data/_includes/components/recipe-steps.html +86 -0
- data/_includes/components/recipe-temp.html +45 -0
- data/_includes/components/search-modal.html +2 -2
- data/_includes/components/shortcuts-modal.html +3 -0
- data/_includes/components/theme-controls-bar.html +10 -2
- data/_includes/components/theme-customizer.html +8 -2
- data/_includes/components/theme-info.html +8 -1
- data/_includes/content/jsonld-software.html +22 -3
- data/_includes/content/seo.html +9 -3
- data/_includes/core/color-mode-init.html +13 -4
- data/_includes/core/favicon.html +46 -0
- data/_includes/core/footer.html +18 -16
- data/_includes/core/head.html +21 -0
- data/_includes/core/header.html +7 -4
- data/_includes/custom/body-end.html +18 -0
- data/_includes/custom/body-start.html +17 -0
- data/_includes/custom/footer.html +18 -0
- data/_includes/custom/head.html +18 -0
- data/_includes/navigation/local-graph.html +28 -2
- data/_includes/navigation/nav-tree.html +3 -3
- data/_includes/navigation/section-sidebar.html +93 -11
- data/_includes/navigation/sidebar-config.html +21 -0
- data/_includes/navigation/sidebar-left.html +2 -1
- data/_includes/navigation/sidebar-nav.html +4 -0
- data/_includes/navigation/sidebar-pagetree.html +150 -0
- data/_includes/navigation/sidebar-right.html +2 -1
- data/_includes/navigation/unified-drawer.html +8 -2
- data/_includes/obsidian/full-graph.html +165 -136
- data/_includes/setup/wizard.html +173 -86
- data/_includes/stats/stats-metrics.html +2 -2
- data/_layouts/404.html +260 -0
- data/_layouts/README.md +4 -0
- data/_layouts/admin.html +2 -2
- data/_layouts/article.html +5 -0
- data/_layouts/book-abc.html +106 -0
- data/_layouts/book-story.html +91 -0
- data/_layouts/book.html +113 -0
- data/_layouts/collection.html +17 -5
- data/_layouts/cookbook.html +88 -0
- data/_layouts/default.html +4 -4
- data/_layouts/home.html +5 -2
- data/_layouts/landing.html +9 -0
- data/_layouts/news.html +155 -31
- data/_layouts/recipe.html +274 -0
- data/_layouts/root.html +38 -5
- data/_layouts/section.html +96 -25
- data/_sass/components/_book.scss +423 -0
- data/_sass/components/_callout.scss +1 -1
- data/_sass/components/_footer.scss +37 -1
- data/_sass/components/_page-views.scss +36 -0
- data/_sass/components/_recipe.scss +506 -0
- data/_sass/components/_setup-wizard.scss +235 -0
- data/_sass/components/_ui-enhancements.scss +6 -6
- data/_sass/core/_navbar.scss +263 -15
- data/_sass/core/_obsidian.scss +286 -6
- data/_sass/layouts/_landing.scss +2 -2
- data/_sass/theme/_backgrounds.scss +21 -8
- data/_sass/tokens/_color.scss +6 -0
- data/_sass/tokens/_index.scss +2 -0
- data/_sass/tokens/_radius.scss +21 -0
- data/_sass/tokens/_typography.scss +4 -0
- data/_sass/utilities/_focus.scss +14 -0
- data/assets/css/main.scss +4 -0
- data/assets/js/auto-hide-nav.js +5 -1
- data/assets/js/halfmoon.js +26 -0
- data/assets/js/modules/navigation/navbar.js +55 -0
- data/assets/js/obsidian-graph.js +702 -264
- data/assets/js/obsidian-local-graph.js +161 -54
- data/assets/js/page-views.js +372 -0
- data/assets/js/recipe-scaler.js +501 -0
- data/assets/js/search-modal.js +14 -1
- data/assets/js/setup-wizard.js +322 -20
- data/scripts/README.md +29 -0
- data/scripts/bin/audit-consumer +39 -7
- data/scripts/bin/giscus-discussions +213 -14
- data/scripts/bin/manifest +66 -16
- data/scripts/bin/validate +5 -1
- data/scripts/ci/agent_review_result.py +164 -0
- data/scripts/ci/test_agent_review_result.py +172 -0
- data/scripts/design-system-check.rb +170 -0
- data/scripts/install/README.md +47 -6
- data/scripts/install/ai/client.sh +302 -93
- data/scripts/install/ai/prompts/spec.schema.json +1 -1
- data/scripts/install/ai/wizard.sh +10 -5
- data/scripts/install/apply.sh +7 -3
- data/scripts/install/cli.sh +54 -4
- data/scripts/install/config.sh +167 -0
- data/scripts/install/doctor.sh +38 -0
- data/scripts/install/plan.sh +10 -0
- data/scripts/install/spec.sh +15 -7
- data/scripts/install/template.sh +4 -0
- data/scripts/lib/audit.sh +42 -2
- data/scripts/lint-liquid-raw.rb +137 -0
- data/scripts/propagate.rb +277 -0
- data/scripts/test/lib/run_tests.sh +2 -1
- data/scripts/test/lib/test_agent_review_result.sh +27 -0
- data/scripts/translate.rb +161 -15
- 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
|
-
@
|
|
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."
|
|
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
|
-
|
|
392
|
-
|
|
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
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|