jekyll-theme-zer0 1.28.0 → 1.30.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 (191) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1143 -13
  3. data/_data/README.md +2 -0
  4. data/_data/ai.yml +5 -3
  5. data/_data/ai_pricing.yml +36 -0
  6. data/_data/backlog.yml +507 -2
  7. data/_data/consumers.yml +157 -9
  8. data/_data/features.yml +303 -20
  9. data/_data/feedback_types.yml +17 -12
  10. data/_data/i18n/fr.yml +12 -7
  11. data/_data/i18n/manifest.yml +39 -12
  12. data/_data/ingredient_densities.yml +122 -0
  13. data/_data/landing.yml +5 -2
  14. data/_data/navigation/main.yml +16 -0
  15. data/_data/navigation/quickstart.yml +4 -0
  16. data/_data/recipe_courses.yml +64 -0
  17. data/_data/site_builder.yml +874 -0
  18. data/_data/theme-manifest.yml +160 -124
  19. data/_data/ui-text.yml +26 -0
  20. data/_includes/README.md +26 -2
  21. data/_includes/analytics/posthog.html +2 -2
  22. data/_includes/components/admin-links.html +2 -2
  23. data/_includes/components/admin-tabs.html +2 -2
  24. data/_includes/components/ai-chat.html +14 -11
  25. data/_includes/components/analytics-dashboard.html +8 -8
  26. data/_includes/components/author-bio.html +1 -1
  27. data/_includes/components/author-card.html +10 -2
  28. data/_includes/components/author-eeat.html +4 -4
  29. data/_includes/components/background-customizer.html +10 -10
  30. data/_includes/components/background-image.html +114 -0
  31. data/_includes/components/background-settings.html +28 -15
  32. data/_includes/components/collection-manager.html +5 -5
  33. data/_includes/components/component-showcase.html +13 -13
  34. data/_includes/components/config-editor.html +12 -12
  35. data/_includes/components/config-viewer.html +8 -8
  36. data/_includes/components/cookie-consent.html +15 -15
  37. data/_includes/components/cta-button.html +7 -2
  38. data/_includes/components/dev-shortcuts.html +7 -7
  39. data/_includes/components/env-dashboard.html +8 -8
  40. data/_includes/components/env-switcher.html +9 -9
  41. data/_includes/components/feature-card.html +2 -2
  42. data/_includes/components/halfmoon.html +2 -2
  43. data/_includes/components/info-section.html +42 -37
  44. data/_includes/components/js-cdn.html +15 -15
  45. data/_includes/components/language-toggle.html +168 -21
  46. data/_includes/components/mermaid.html +72 -435
  47. data/_includes/components/nanobar.html +5 -5
  48. data/_includes/components/nav-editor.html +2 -2
  49. data/_includes/components/nav-export.html +2 -2
  50. data/_includes/components/nav-overview.html +2 -2
  51. data/_includes/components/page-feedback.html +45 -30
  52. data/_includes/components/page-views-init.html +55 -0
  53. data/_includes/components/page-views.html +33 -0
  54. data/_includes/components/post-card.html +22 -22
  55. data/_includes/components/post-type-badge.html +2 -2
  56. data/_includes/components/powered-by.html +2 -2
  57. data/_includes/components/preview-image.html +6 -0
  58. data/_includes/components/quick-index.html +2 -2
  59. data/_includes/components/recipe-card.html +67 -0
  60. data/_includes/components/recipe-duration.html +50 -0
  61. data/_includes/components/recipe-grams.html +58 -0
  62. data/_includes/components/recipe-index.html +96 -0
  63. data/_includes/components/recipe-ingredients.html +90 -0
  64. data/_includes/components/recipe-meta.html +96 -0
  65. data/_includes/components/recipe-nutrition.html +57 -0
  66. data/_includes/components/recipe-qty.html +73 -0
  67. data/_includes/components/recipe-ratio.html +151 -0
  68. data/_includes/components/recipe-scaler.html +73 -0
  69. data/_includes/components/recipe-steps.html +86 -0
  70. data/_includes/components/recipe-temp.html +45 -0
  71. data/_includes/components/search-modal.html +29 -4
  72. data/_includes/components/searchbar.html +2 -2
  73. data/_includes/components/shortcuts-modal.html +3 -0
  74. data/_includes/components/svg-background.html +2 -2
  75. data/_includes/components/theme-customizer.html +2 -2
  76. data/_includes/components/theme-info.html +14 -7
  77. data/_includes/components/theme-preview-gallery.html +22 -22
  78. data/_includes/content/giscus.html +2 -2
  79. data/_includes/content/intro.html +8 -8
  80. data/_includes/content/jsonld-faq.html +2 -2
  81. data/_includes/content/jsonld-software.html +24 -5
  82. data/_includes/content/seo.html +4 -4
  83. data/_includes/content/sitemap.html +27 -27
  84. data/_includes/content/toc.html +183 -183
  85. data/_includes/core/branding.html +6 -6
  86. data/_includes/core/console-capture.html +32 -74
  87. data/_includes/core/favicon.html +49 -7
  88. data/_includes/core/footer-fabs.html +17 -3
  89. data/_includes/core/footer.html +49 -34
  90. data/_includes/core/head.html +110 -86
  91. data/_includes/core/header.html +76 -54
  92. data/_includes/docs/bootstrap-docs.html +8 -8
  93. data/_includes/landing/landing-install-cards.html +2 -2
  94. data/_includes/landing/landing-quick-links.html +1 -1
  95. data/_includes/navigation/admin-nav.html +2 -2
  96. data/_includes/navigation/nav-tree.html +8 -8
  97. data/_includes/navigation/navbar.html +12 -12
  98. data/_includes/navigation/section-sidebar.html +109 -27
  99. data/_includes/navigation/sidebar-config.html +36 -2
  100. data/_includes/navigation/sidebar-left.html +17 -16
  101. data/_includes/navigation/sidebar-right.html +8 -7
  102. data/_includes/obsidian/full-graph.html +2 -2
  103. data/_includes/setup/claude-session.html +72 -0
  104. data/_includes/setup/prereq-checklist.html +90 -0
  105. data/_includes/setup/wizard.html +924 -222
  106. data/_includes/stats/stats-categories.html +8 -8
  107. data/_includes/stats/stats-header.html +14 -14
  108. data/_includes/stats/stats-metrics.html +14 -14
  109. data/_includes/stats/stats-no-data.html +12 -12
  110. data/_includes/stats/stats-overview.html +6 -6
  111. data/_includes/stats/stats-tags.html +8 -8
  112. data/_layouts/404.html +38 -24
  113. data/_layouts/README.md +2 -0
  114. data/_layouts/admin.html +24 -24
  115. data/_layouts/article.html +43 -33
  116. data/_layouts/author.html +20 -20
  117. data/_layouts/authors.html +2 -2
  118. data/_layouts/book-abc.html +12 -12
  119. data/_layouts/book-story.html +15 -15
  120. data/_layouts/book.html +12 -12
  121. data/_layouts/collection.html +33 -33
  122. data/_layouts/cookbook.html +88 -0
  123. data/_layouts/default.html +31 -28
  124. data/_layouts/home.html +23 -23
  125. data/_layouts/index.html +10 -10
  126. data/_layouts/landing.html +17 -17
  127. data/_layouts/news.html +44 -44
  128. data/_layouts/note.html +38 -38
  129. data/_layouts/notebook.html +34 -34
  130. data/_layouts/recipe.html +274 -0
  131. data/_layouts/root.html +92 -55
  132. data/_layouts/section.html +62 -33
  133. data/_layouts/setup.html +3 -3
  134. data/_layouts/sitemap-collection.html +49 -49
  135. data/_layouts/stats.html +40 -40
  136. data/_layouts/tag.html +12 -12
  137. data/_layouts/welcome.html +21 -21
  138. data/_sass/components/_callout.scss +1 -1
  139. data/_sass/components/_footer.scss +37 -1
  140. data/_sass/components/_mermaid.scss +375 -0
  141. data/_sass/components/_page-views.scss +36 -0
  142. data/_sass/components/_recipe.scss +506 -0
  143. data/_sass/components/_setup-wizard.scss +764 -0
  144. data/_sass/components/_ui-enhancements.scss +6 -6
  145. data/_sass/core/_navbar.scss +261 -46
  146. data/_sass/layouts/_landing.scss +2 -2
  147. data/_sass/layouts/_navbar-extras.scss +14 -4
  148. data/_sass/tokens/_color.scss +6 -0
  149. data/_sass/tokens/_index.scss +2 -0
  150. data/_sass/tokens/_radius.scss +21 -0
  151. data/_sass/tokens/_typography.scss +4 -0
  152. data/_sass/utilities/_focus.scss +14 -0
  153. data/assets/css/main.scss +4 -0
  154. data/assets/js/ai-chat.js +47 -5
  155. data/assets/js/fleet-feedback-capture.js +124 -0
  156. data/assets/js/fleet-feedback.js +853 -0
  157. data/assets/js/mermaid-diagrams.js +1267 -0
  158. data/assets/js/modules/navigation/config.js +9 -6
  159. data/assets/js/modules/navigation/navbar.js +55 -0
  160. data/assets/js/modules/navigation/scroll-spy.js +315 -80
  161. data/assets/js/modules/theme/appearance.js +8 -2
  162. data/assets/js/obsidian-wiki-links.js +8 -3
  163. data/assets/js/page-feedback.js +125 -192
  164. data/assets/js/page-views.js +372 -0
  165. data/assets/js/recipe-scaler.js +501 -0
  166. data/assets/js/search-modal.js +36 -0
  167. data/assets/js/setup-wizard.js +2279 -226
  168. data/assets/js/site-builder.js +1834 -0
  169. data/assets/js/ui-enhancements.js +11 -3
  170. data/scripts/README.md +44 -0
  171. data/scripts/ai/README.md +38 -0
  172. data/scripts/ai/api_call.rb +124 -0
  173. data/scripts/ai/usage.rb +314 -0
  174. data/scripts/ai/usage_report.rb +225 -0
  175. data/scripts/bin/audit-consumer +39 -7
  176. data/scripts/bin/giscus-discussions +213 -14
  177. data/scripts/bin/manifest +35 -12
  178. data/scripts/ci/agent_review_result.py +164 -0
  179. data/scripts/ci/test_agent_review_result.py +172 -0
  180. data/scripts/ci/test_visual_evidence_autogen.py +341 -0
  181. data/scripts/ci/visual_evidence_autogen.py +1060 -0
  182. data/scripts/content-review.rb +20 -1
  183. data/scripts/design-system-check.rb +170 -0
  184. data/scripts/lib/audit.sh +42 -2
  185. data/scripts/lint-liquid-raw.rb +137 -0
  186. data/scripts/test/integration/mermaid +22 -8
  187. data/scripts/test/lib/run_tests.sh +3 -1
  188. data/scripts/test/lib/test_agent_review_result.sh +27 -0
  189. data/scripts/test/lib/test_visual_evidence_autogen.sh +24 -0
  190. data/scripts/translate.rb +94 -16
  191. metadata +48 -2
@@ -0,0 +1,225 @@
1
+ #!/usr/bin/env ruby
2
+ # =============================================================================
3
+ # usage_report.rb — publish a job's AI usage records (summary, artifact, PR comment)
4
+ # -----------------------------------------------------------------------------
5
+ # The reporting half of AI metering. usage.rb captured one JSONL record per AI
6
+ # call into AI_USAGE_DIR/records.jsonl; this script, run at the end of the
7
+ # job (the claude-run composite calls it automatically), does four things:
8
+ #
9
+ # 1. ATTRIBUTE — resolve which PR the spend belongs to: an explicit --pr,
10
+ # the pull_request event payload, or the pr-result.txt file
11
+ # the factory/fleet agents write after opening a PR (that
12
+ # run's records become the PR's CREATION cost).
13
+ # 2. CONSUME — move records.jsonl to a reported-*.jsonl file (so a second
14
+ # AI step in the same job never double-reports) and print
15
+ # `file=` / `name=` to $GITHUB_OUTPUT for the artifact upload.
16
+ # 3. SUMMARIZE — append a per-call table to $GITHUB_STEP_SUMMARY.
17
+ # 4. COMMENT — upsert ONE sticky "AI usage & cost" comment on the PR,
18
+ # found by its <!-- lh-ai-usage --> marker (never
19
+ # `--edit-last`, which grabs whatever the bot said last).
20
+ # The comment embeds its own base64 data blob, so each run
21
+ # merges records by id — cumulative, idempotent, and safe to
22
+ # re-run. Concurrent jobs can still race the read-merge-write
23
+ # (last writer wins for the VIEW); the artifacts + nightly
24
+ # ledger remain the source of truth.
25
+ #
26
+ # Every dollar figure is API-equivalent: what the tokens would bill at list
27
+ # prices. Subscription (OAuth) runs cost $0 marginal — the label says so.
28
+ # Best-effort by design: metering must never fail the job. Stdlib + `gh` only.
29
+ #
30
+ # ruby scripts/ai/usage_report.rb [--pr N] [--pr-result pr-result.txt]
31
+ # =============================================================================
32
+ require 'json'
33
+ require 'time'
34
+ require 'digest'
35
+ require 'securerandom'
36
+ require_relative 'usage'
37
+
38
+ MARKER = '<!-- lh-ai-usage -->'.freeze
39
+ DATA_HEAD = '<!-- lh-ai-usage-data:'.freeze
40
+ MAX_BLOB_RECORDS = 150 # older records fold into a rollup so the comment stays < 64KB
41
+ MAX_TABLE_ROWS = 30
42
+
43
+ pr_arg = nil
44
+ pr_result_file = 'pr-result.txt'
45
+ args = ARGV.dup
46
+ until args.empty?
47
+ case (a = args.shift)
48
+ when '--pr' then pr_arg = args.shift.to_i
49
+ when '--pr-result' then pr_result_file = args.shift.to_s
50
+ end
51
+ end
52
+
53
+ # --- 1. load this job's records ------------------------------------------------
54
+ src = AIUsage.records_path
55
+ records = File.exist?(src) ? File.read(src, encoding: 'UTF-8').split("\n").map { |l| JSON.parse(l) rescue nil }.compact : []
56
+ if records.empty?
57
+ warn '[usage_report] no AI usage records this job — nothing to report.'
58
+ File.open(ENV['GITHUB_OUTPUT'], 'a') { |io| io.puts('file='); io.puts('name=') } if ENV['GITHUB_OUTPUT']
59
+ exit 0
60
+ end
61
+
62
+ # --- 2. attribute to a PR --------------------------------------------------------
63
+ pr = nil
64
+ pr_source = nil
65
+ if pr_arg && pr_arg > 0
66
+ pr = pr_arg
67
+ pr_source = 'event'
68
+ elsif ENV['GITHUB_EVENT_PATH'] && File.exist?(ENV['GITHUB_EVENT_PATH'])
69
+ ev = JSON.parse(File.read(ENV['GITHUB_EVENT_PATH'])) rescue {}
70
+ n = ev.dig('pull_request', 'number')
71
+ if n
72
+ pr = n.to_i
73
+ pr_source = 'event'
74
+ end
75
+ end
76
+ if pr.nil? && File.exist?(pr_result_file)
77
+ # The factory/fleet convention: the agent writes the PR/issue URL(s) it opened
78
+ # to pr-result.txt. The first pull URL is the PR this run CREATED — its spend
79
+ # is that PR's creation cost.
80
+ if (m = File.read(pr_result_file, encoding: 'UTF-8')[%r{/pull/(\d+)}, 1])
81
+ pr = m.to_i
82
+ pr_source = 'created'
83
+ end
84
+ end
85
+ records.each { |r| r['pr'] ||= pr; r['pr_source'] ||= pr_source if pr }
86
+
87
+ # --- 3. consume: move to a reported file, hand the path to the uploader ----------
88
+ reported = File.join(AIUsage.dir, "reported-#{Time.now.utc.strftime('%H%M%S')}-#{SecureRandom.hex(3)}.jsonl")
89
+ File.open(reported, 'w') { |io| records.each { |r| io.puts(JSON.generate(r)) } }
90
+ File.delete(src)
91
+ if ENV['GITHUB_OUTPUT']
92
+ artifact = "ai-usage-#{ENV['GITHUB_RUN_ID'] || 'local'}-#{(ENV['GITHUB_JOB'] || 'job').gsub(/[^A-Za-z0-9_-]/, '_')}-#{SecureRandom.hex(3)}"
93
+ File.open(ENV['GITHUB_OUTPUT'], 'a') { |io| io.puts("file=#{reported}"); io.puts("name=#{artifact}") }
94
+ end
95
+
96
+ fmt_usd = ->(v) { format('$%.4f', v.to_f) }
97
+ fmt_tok = ->(v) { v.to_i >= 10_000 ? "#{(v.to_i / 1000.0).round}k" : v.to_i.to_s }
98
+
99
+ # --- 4. step summary --------------------------------------------------------------
100
+ if ENV['GITHUB_STEP_SUMMARY']
101
+ total = records.sum { |r| r['cost_usd'].to_f }
102
+ lines = []
103
+ lines << '## 🤖 AI usage (this job)'
104
+ lines << ''
105
+ lines << '| role | model | turns | in | out | cache r/w | cost (API-equiv) | via |'
106
+ lines << '|---|---|---|---|---|---|---|---|'
107
+ records.each do |r|
108
+ t = r['tokens'] || {}
109
+ lines << "| #{r['agent'].to_s.empty? ? '—' : r['agent']} | #{r['model']} | #{r['num_turns'] || '—'} " \
110
+ "| #{fmt_tok.call(t['input'])} | #{fmt_tok.call(t['output'])} " \
111
+ "| #{fmt_tok.call(t['cache_read'])}/#{fmt_tok.call(t['cache_creation'])} " \
112
+ "| #{fmt_usd.call(r['cost_usd'])}#{r['cost_source'] == 'estimated' ? '*' : ''} | #{r['auth']} |"
113
+ end
114
+ lines << ''
115
+ # A failed call bills nothing, so it is invisible in the table above — spell
116
+ # out what the model refused, right where the operator is already looking.
117
+ records.select { |r| r['error'] }.each do |r|
118
+ e = r['error']
119
+ lines << "> ❌ **#{r['agent'].to_s.empty? ? 'AI call' : r['agent']} failed** " \
120
+ "(`#{e['subtype'].to_s.empty? ? "exit #{e['exit_code']}" : e['subtype']}`): #{e['message']}"
121
+ lines << ''
122
+ end
123
+ lines << "**Job total: #{fmt_usd.call(total)}** (API-equivalent#{records.any? { |r| r['cost_source'] == 'estimated' } ? '; * = estimated from _data/ai_pricing.yml' : ''})."
124
+ lines << ''
125
+ File.open(ENV['GITHUB_STEP_SUMMARY'], 'a') { |io| io.puts(lines.join("\n")) }
126
+ end
127
+
128
+ # --- 5. sticky PR comment (best-effort) --------------------------------------------
129
+ repo = ENV['GITHUB_REPOSITORY'].to_s
130
+ exit 0 if pr.nil? || repo.empty?
131
+ unless system('gh --version > /dev/null 2>&1') && !(ENV['GH_TOKEN'].to_s + ENV['GITHUB_TOKEN'].to_s).empty?
132
+ warn '[usage_report] no gh/token — skipping the PR comment (records still in the artifact).'
133
+ exit 0
134
+ end
135
+
136
+ def gh_json(args)
137
+ out = IO.popen(['gh'] + args, err: %i[child out], &:read)
138
+ return nil unless $?.success?
139
+ JSON.parse(out)
140
+ rescue StandardError
141
+ nil
142
+ end
143
+
144
+ # Find the existing sticky comment by MARKER (any author, any position).
145
+ existing = nil
146
+ page = 1
147
+ loop do
148
+ batch = gh_json(['api', "repos/#{repo}/issues/#{pr}/comments?per_page=100&page=#{page}"])
149
+ break unless batch.is_a?(Array)
150
+ existing = batch.find { |c| c['body'].to_s.start_with?(MARKER) }
151
+ break if existing || batch.size < 100 || page >= 10
152
+ page += 1
153
+ end
154
+
155
+ # Merge this job's records into the comment's embedded blob (dedup by id).
156
+ blob = { 'records' => [], 'folded' => nil }
157
+ if existing && (m = existing['body'].to_s[/#{Regexp.escape(DATA_HEAD)}([A-Za-z0-9+\/=]+) -->/, 1])
158
+ blob = JSON.parse(m.unpack1('m')) rescue { 'records' => [], 'folded' => nil }
159
+ end
160
+ compact = ->(r) do
161
+ t = r['tokens'] || {}
162
+ { 'i' => r['id'], 't' => r['ts'], 'w' => r['workflow'], 'j' => r['job'], 'r' => r['run_id'],
163
+ 'a' => r['agent'], 'm' => r['model'], 'ti' => t['input'].to_i, 'to' => t['output'].to_i,
164
+ 'tr' => t['cache_read'].to_i, 'tc' => t['cache_creation'].to_i,
165
+ 'c' => r['cost_usd'].to_f.round(6), 's' => r['cost_source'], 'au' => r['auth'],
166
+ 'st' => r['status'], 'ps' => r['pr_source'] }
167
+ end
168
+ known = blob['records'].map { |r| r['i'] }
169
+ records.each { |r| blob['records'] << compact.call(r) unless known.include?(r['id']) }
170
+ blob['records'].sort_by! { |r| r['t'].to_s }
171
+ while blob['records'].size > MAX_BLOB_RECORDS
172
+ old = blob['records'].shift
173
+ f = blob['folded'] ||= { 'n' => 0, 'c' => 0.0, 'ti' => 0, 'to' => 0 }
174
+ f['n'] += 1
175
+ f['c'] = (f['c'] + old['c'].to_f).round(6)
176
+ f['ti'] += old['ti'].to_i
177
+ f['to'] += old['to'].to_i
178
+ end
179
+
180
+ all = blob['records']
181
+ folded = blob['folded']
182
+ total = all.sum { |r| r['c'].to_f } + (folded ? folded['c'].to_f : 0.0)
183
+ creation = all.select { |r| r['ps'] == 'created' }.sum { |r| r['c'].to_f }
184
+ downstream = total - creation
185
+ estimated = all.any? { |r| r['s'] == 'estimated' }
186
+ oauth_only = all.all? { |r| r['au'] == 'oauth' }
187
+
188
+ body = []
189
+ body << MARKER
190
+ body << '## 🤖 AI usage & cost for this PR'
191
+ body << ''
192
+ body << "**Total: #{fmt_usd.call(total)} API-equivalent** across #{all.size + (folded ? folded['n'] : 0)} AI call(s) — " \
193
+ "creation #{fmt_usd.call(creation)}, reviews/fixes/checks #{fmt_usd.call(downstream)}."
194
+ body << ''
195
+ body << '| when (UTC) | workflow · job | role | model | out tok | cost |'
196
+ body << '|---|---|---|---|---|---|'
197
+ body << "| _earlier_ | _#{folded['n']} older call(s), folded_ | | | #{fmt_tok.call(folded['to'])} | #{fmt_usd.call(folded['c'])} |" if folded
198
+ all.last(MAX_TABLE_ROWS).each do |r|
199
+ run_link = r['r'].to_s.empty? ? (r['w'].to_s.empty? ? 'local' : r['w']) : "[#{r['w']} · #{r['j']}](https://github.com/#{repo}/actions/runs/#{r['r']})"
200
+ body << "| #{r['t'].to_s[5, 11]} | #{run_link} | #{r['a'].to_s.empty? ? '—' : r['a']}#{r['ps'] == 'created' ? ' 🌱' : ''} " \
201
+ "| #{r['m']} | #{fmt_tok.call(r['to'])} | #{fmt_usd.call(r['c'])}#{r['s'] == 'estimated' ? '*' : ''} |"
202
+ end
203
+ body << "| | _…#{all.size - MAX_TABLE_ROWS} more in the ledger_ | | | | |" if all.size > MAX_TABLE_ROWS
204
+ body << ''
205
+ notes = ['🌱 = the run that opened this PR (creation cost).']
206
+ notes << '\\* = estimated from `_data/ai_pricing.yml` (that path reports tokens, not dollars).' if estimated
207
+ notes << (oauth_only ? 'All calls ran on Claude Code subscription auth (OAuth) — $0 marginal spend; the figure is what these tokens would bill at API list prices.' \
208
+ : 'Some calls used a metered API key — those dollars are real.')
209
+ notes << 'Updated automatically after every AI job; full history at [/docs/ai-usage/](https://lifehacker.dev/docs/ai-usage/).'
210
+ body << notes.map { |n| "_#{n}_" }.join(' ')
211
+ body << ''
212
+ body << "#{DATA_HEAD}#{[JSON.generate(blob)].pack('m0')} -->"
213
+
214
+ payload = JSON.generate('body' => body.join("\n"))
215
+ tmp = File.join(AIUsage.dir, 'comment-payload.json')
216
+ File.write(tmp, payload)
217
+ ok =
218
+ if existing
219
+ system('gh', 'api', '-X', 'PATCH', "repos/#{repo}/issues/comments/#{existing['id']}", '--input', tmp, out: File::NULL, err: %i[child out])
220
+ else
221
+ system('gh', 'api', "repos/#{repo}/issues/#{pr}/comments", '--input', tmp, out: File::NULL, err: %i[child out])
222
+ end
223
+ warn(ok ? "[usage_report] PR ##{pr} cost comment #{existing ? 'updated' : 'created'} (total #{fmt_usd.call(total)})." \
224
+ : "[usage_report] PR ##{pr} comment update failed (non-fatal; records are in the artifact).")
225
+ exit 0
@@ -118,14 +118,35 @@ done
118
118
 
119
119
  CONSUMER_PATH="$(cd "$CONSUMER_PATH" && pwd)"
120
120
 
121
+ # In --format json, stdout must be PURE JSON so `jq` / `json.load` can consume
122
+ # it directly. The human-readable log lines are still useful, so send them to
123
+ # stderr rather than dropping them. (Left on stdout, they made every --format
124
+ # json run unparseable even after the trailing-comma fix.)
125
+ if [[ "$FORMAT" == "json" ]]; then
126
+ info() { echo -e "${BLUE}[INFO]${NC} $1" >&2; }
127
+ step() { echo -e "${CYAN}[STEP]${NC} $1" >&2; }
128
+ success() { echo -e "${GREEN}[SUCCESS]${NC} $1" >&2; }
129
+ warn() { echo -e "${YELLOW}[WARNING]${NC} $1" >&2; }
130
+ fi
131
+
121
132
  # ---------------------------------------------------------------------------
122
133
  step "Auditing consumer: $CONSUMER_PATH"
123
134
 
124
- # Detect or confirm mode
135
+ # CONSUMER_ROOT holds the config/override files; CONSUMER_PATH is where the
136
+ # theme-shadowing files actually live. They differ when the consumer sets
137
+ # Jekyll's `source:` (zer0-pages keeps its whole site tree under `pages/`).
138
+ CONSUMER_ROOT="$CONSUMER_PATH"
139
+ CONSUMER_SOURCE_REL=$(detect_consumer_source "$CONSUMER_ROOT")
140
+ if [[ -n "$CONSUMER_SOURCE_REL" && -d "$CONSUMER_ROOT/$CONSUMER_SOURCE_REL" ]]; then
141
+ CONSUMER_PATH="$CONSUMER_ROOT/$CONSUMER_SOURCE_REL"
142
+ info "Jekyll source: $CONSUMER_SOURCE_REL/ — scanning there, not the repo root"
143
+ fi
144
+
145
+ # Detect or confirm mode (from the repo root, where _config.yml lives)
125
146
  if [[ -n "$MODE_OVERRIDE" ]]; then
126
147
  MODE="$MODE_OVERRIDE"
127
148
  else
128
- MODE=$(detect_consumer_mode "$CONSUMER_PATH")
149
+ MODE=$(detect_consumer_mode "$CONSUMER_ROOT")
129
150
  info "Detected mode: $MODE"
130
151
  fi
131
152
 
@@ -133,7 +154,7 @@ fi
133
154
  if [[ -n "$THEME_PATH_OVERRIDE" ]]; then
134
155
  RESOLVED_THEME="$THEME_PATH_OVERRIDE"
135
156
  else
136
- RESOLVED_THEME=$(resolve_theme_path "$MODE" "$CONSUMER_PATH" "$THEME_ROOT")
157
+ RESOLVED_THEME=$(resolve_theme_path "$MODE" "$CONSUMER_ROOT" "$THEME_ROOT")
137
158
  fi
138
159
 
139
160
  if [[ -z "$RESOLVED_THEME" || ! -d "$RESOLVED_THEME" ]]; then
@@ -149,7 +170,10 @@ if [[ ! -f "$MANIFEST" ]]; then
149
170
  fi
150
171
 
151
172
  # Load overrides manifest from consumer
152
- OVERRIDES_FILE="$CONSUMER_PATH/.theme-overrides.yml"
173
+ # Declared overrides sit next to _config.yml at the repo root; fall back to the
174
+ # source dir so either placement works.
175
+ OVERRIDES_FILE="$CONSUMER_ROOT/.theme-overrides.yml"
176
+ [[ -f "$OVERRIDES_FILE" ]] || OVERRIDES_FILE="$CONSUMER_PATH/.theme-overrides.yml"
153
177
  OVERRIDE_PATHS_TMP=$(mktemp)
154
178
  trap 'rm -f "$OVERRIDE_PATHS_TMP"' EXIT
155
179
 
@@ -190,8 +214,14 @@ if [[ ${#THEMABLE_DIRS[@]} -eq 0 ]]; then
190
214
  THEMABLE_DIRS=("_layouts" "_includes" "_sass" "assets/css" "assets/js")
191
215
  fi
192
216
  if [[ ${#REQUIRED_PLUGIN_FILES[@]} -eq 0 && ${#OPTIONAL_PLUGIN_FILES[@]} -eq 0 ]]; then
193
- REQUIRED_PLUGIN_FILES=("_plugins/obsidian_links.rb")
194
- OPTIONAL_PLUGIN_FILES=("_plugins/admin_page_urls.rb" "_plugins/content_statistics_generator.rb" "_plugins/theme_version.rb")
217
+ # No manifest at all. Nothing is genuinely required (GitHub Pages loads no
218
+ # local plugins), so every known plugin is offered as optional.
219
+ OPTIONAL_PLUGIN_FILES=(
220
+ "_plugins/obsidian_links.rb"
221
+ "_plugins/admin_page_urls.rb"
222
+ "_plugins/content_statistics_generator.rb"
223
+ "_plugins/theme_version.rb"
224
+ )
195
225
  fi
196
226
 
197
227
  # ---------------------------------------------------------------------------
@@ -316,7 +346,9 @@ fi
316
346
  # ---------------------------------------------------------------------------
317
347
  # Summary
318
348
  # ---------------------------------------------------------------------------
319
- [[ "$FORMAT" == "json" ]] && echo ']}'
349
+ # Entries are printed without a trailing newline (see print_classification),
350
+ # so close the array on its own line.
351
+ [[ "$FORMAT" == "json" ]] && printf '\n]}\n'
320
352
 
321
353
  if [[ "$FORMAT" == "text" ]]; then
322
354
  echo ""
@@ -6,6 +6,7 @@
6
6
  # `giscus-conversation` skill drives to build conversations from page comments.
7
7
  #
8
8
  # Subcommands:
9
+ # doctor Verify the comment chain end-to-end (app installed, IDs match)
9
10
  # categories List the repo's discussion categories + node IDs (setup helper)
10
11
  # list List discussions in the configured Giscus category
11
12
  # thread Print a discussion's full conversation (comments + replies)
@@ -72,14 +73,22 @@ resolve_category() {
72
73
  owner_of() { printf '%s' "${1%%/*}"; }
73
74
  name_of() { printf '%s' "${1##*/}"; }
74
75
 
75
- # Normalize a page path so it matches a Giscus pathname-mapped discussion title.
76
+ # Normalize a page path into the *Giscus term* — the string Giscus actually
77
+ # searches discussion titles for under data-mapping="pathname".
78
+ #
79
+ # Giscus uses location.pathname with the leading slash STRIPPED. Verified live:
80
+ # /posts/2025/01/21/remote-work-revolution/ produces a widget iframe with
81
+ # term=posts%2F2025%2F01%2F21%2Fremote-work-revolution%2F
82
+ # This previously prepended a slash instead, so `seed` created titles Giscus
83
+ # would never match, and `--page` lookups missed every thread Giscus itself
84
+ # created. Callers may still pass a leading slash or a full URL.
76
85
  normalize_page() {
77
86
  local p="$1"
78
87
  if [[ "$p" == *"://"* ]]; then
79
88
  p="${p#*://}" # strip scheme
80
- p="/${p#*/}" # strip host, keep path with a leading slash
89
+ p="${p#*/}" # strip host, keep the path
81
90
  fi
82
- [[ "$p" != /* ]] && p="/$p"
91
+ p="${p#/}" # Giscus's pathname term carries no leading slash
83
92
  printf '%s' "$p"
84
93
  }
85
94
 
@@ -95,15 +104,26 @@ repo_node_id() {
95
104
  --jq '.data.repository.id'
96
105
  }
97
106
 
98
- # Find a discussion number whose title equals the (normalized) page path.
99
- # Echoes the number, or nothing if not found.
107
+ # Find a discussion number whose title matches the page. Echoes the number, or
108
+ # nothing if not found.
109
+ #
110
+ # Matches four title spellings so lookups work against BOTH threads Giscus
111
+ # created (its own term: no leading slash) and any hand-made or legacy thread
112
+ # titled with a leading slash. Paginates: the previous version declared $after
113
+ # and pageInfo but never used them, so on a site with more than 100 discussions
114
+ # it silently failed to find anything past the first page.
100
115
  discussion_number_for_page() {
101
- local page; page="$(normalize_page "$1")"
102
- local page_noslash="${page%/}"
103
- gh api graphql -f query='
104
- query($owner:String!,$name:String!,$categoryId:ID,$after:String){
116
+ local term; term="$(normalize_page "$1")"
117
+ # Match four spellings so lookups work against BOTH threads Giscus created
118
+ # (its own term: no leading slash) and hand-made/legacy threads titled with
119
+ # one. --paginate walks every page: the previous version declared $after and
120
+ # pageInfo but never used them, so on a site with more than 100 discussions
121
+ # it silently failed to find anything past the first page.
122
+ local matches
123
+ matches="$(gh api graphql --paginate -f query='
124
+ query($owner:String!,$name:String!,$categoryId:ID,$endCursor:String){
105
125
  repository(owner:$owner,name:$name){
106
- discussions(first:100,after:$after,categoryId:$categoryId){
126
+ discussions(first:100,after:$endCursor,categoryId:$categoryId){
107
127
  nodes{ number title }
108
128
  pageInfo{ hasNextPage endCursor }
109
129
  }
@@ -111,8 +131,16 @@ discussion_number_for_page() {
111
131
  }' -F owner="$(owner_of "$REPO_NWO")" -F name="$(name_of "$REPO_NWO")" \
112
132
  -f categoryId="$CATEGORY_ID" \
113
133
  --jq ".data.repository.discussions.nodes[]
114
- | select(.title == \"$page\" or .title == \"$page_noslash\")
115
- | .number" | head -1
134
+ | select(.title == \"${term}\"
135
+ or .title == \"${term%/}\"
136
+ or .title == \"/${term}\"
137
+ or .title == \"/${term%/}\")
138
+ | .number" 2>/dev/null)"
139
+ # First match only. Deliberately NOT `| head -1` on the pipeline: head
140
+ # closes the pipe early, gh dies on SIGPIPE, and `set -o pipefail` then
141
+ # aborts the whole script inside the surrounding command substitution.
142
+ local nl=$'\n'
143
+ printf '%s' "${matches%%${nl}*}"
116
144
  }
117
145
 
118
146
  discussion_node_id() {
@@ -260,7 +288,12 @@ resolve_number_arg() {
260
288
  elif [[ -n "$page" ]]; then
261
289
  resolve_category
262
290
  NUMBER="$(discussion_number_for_page "$page")"
263
- [[ -z "$NUMBER" ]] && error "No discussion found for page '$page'. Seed one with: $0 seed --page '$page' --title ... --body ..."
291
+ # `if`, not `[[ ... ]] && error`: as the last statement in this branch a
292
+ # false test returns 1, which `set -e` turns into a silent script abort
293
+ # on the SUCCESS path.
294
+ if [[ -z "$NUMBER" ]]; then
295
+ error "No discussion found for page '$page'. Seed one with: $0 seed --page '$page' --title ... --body ..."
296
+ fi
264
297
  else
265
298
  error "Provide --number N, --url URL, or --page PATH."
266
299
  fi
@@ -401,7 +434,9 @@ cmd_post() {
401
434
  elif [[ -n "$page" ]]; then
402
435
  resolve_category
403
436
  NUMBER="$(discussion_number_for_page "$page")"
404
- [[ -z "$NUMBER" ]] && error "No discussion for page '$page'. Seed one first: $0 seed --page '$page' ..."
437
+ if [[ -z "$NUMBER" ]]; then
438
+ error "No discussion for page '$page'. Seed one first: $0 seed --page '$page' ..."
439
+ fi
405
440
  else
406
441
  error "post: provide --number N, --url URL, or --page PATH."
407
442
  fi
@@ -448,6 +483,167 @@ cmd_post() {
448
483
  success "Comment posted to discussion #$NUMBER"
449
484
  }
450
485
 
486
+ # ---------------------------------------------------------------------------
487
+ # Subcommand: doctor (verify the whole comment chain actually works)
488
+ # ---------------------------------------------------------------------------
489
+
490
+ # Why this exists: every other check in this repo is static. The core test
491
+ # validates _config.yml keys and the include's Liquid; the Playwright spec stubs
492
+ # giscus.app out entirely. All of them pass with a 100% broken widget, because
493
+ # the two things that actually break comments in practice live on GitHub's side,
494
+ # not in any file here:
495
+ # 1. the giscus GitHub App not being installed on the repo
496
+ # 2. a data-repo-id / data-category-id that doesn't belong to this repo
497
+ # `doctor` asks Giscus itself, which is the only authoritative answer.
498
+
499
+ # Read a scalar from the giscus block of _config.yml (same block-scoping as
500
+ # config_category_id, which only handles data-category-id).
501
+ config_giscus_key() {
502
+ local key="$1"
503
+ [[ -f "$CONFIG_FILE" ]] || return 0
504
+ # Block-scoped to the top-level `giscus:` mapping, then strip the key, any
505
+ # trailing comment, and surrounding quotes (\047 is awk for a single quote).
506
+ awk -v k="${key}:" '
507
+ /^giscus:/ { f=1; next }
508
+ f && /^[^[:space:]#]/ { f=0 }
509
+ f && $1 == k {
510
+ sub(/^[[:space:]]*[^:]+:[[:space:]]*/, "")
511
+ sub(/[[:space:]]+#.*$/, "")
512
+ gsub(/^["\047]+|["\047]+[[:space:]]*$/, "")
513
+ print
514
+ exit
515
+ }' "$CONFIG_FILE"
516
+ }
517
+
518
+ # Non-fatal error: report and let the caller accumulate failures. common.sh's
519
+ # error() exits, which would stop doctor at the first problem instead of
520
+ # reporting every one.
521
+ doctor_fail() { printf '\033[0;31m[FAIL]\033[0m %s\n' "$*" >&2; }
522
+
523
+ # One graphql call, one field out — avoids needing a standalone jq.
524
+ gql_repo_field() {
525
+ gh api graphql -f query="$1" \
526
+ -F owner="$(owner_of "$REPO_NWO")" -F name="$(name_of "$REPO_NWO")" \
527
+ --jq "$2" 2>/dev/null
528
+ }
529
+
530
+ REPO_Q='
531
+ query($owner:String!,$name:String!){
532
+ repository(owner:$owner,name:$name){
533
+ id isPrivate hasDiscussionsEnabled
534
+ discussionCategories(first:50){ nodes{ id name } }
535
+ }
536
+ }'
537
+
538
+ cmd_doctor() {
539
+ local category_name=""
540
+ while [[ $# -gt 0 ]]; do
541
+ case "$1" in
542
+ --repo) REPO_NWO="$2"; shift 2 ;;
543
+ --category-id) CATEGORY_ID="$2"; shift 2 ;;
544
+ --category) category_name="$2"; shift 2 ;;
545
+ *) error "doctor: unknown option '$1'" ;;
546
+ esac
547
+ done
548
+ resolve_repo
549
+ require_command gh "install: https://cli.github.com/"
550
+ require_command curl "install: https://curl.se/"
551
+
552
+ local failed=0
553
+ info "Checking the Giscus comment chain for $REPO_NWO"
554
+
555
+ # --- 1. Repo-side facts, straight from the GitHub API -------------------
556
+ local real_repo_id is_private discussions_on
557
+ real_repo_id="$(gql_repo_field "$REPO_Q" '.data.repository.id')"
558
+ [[ -z "$real_repo_id" ]] && error "Could not query $REPO_NWO via gh. Check the repo name and \`gh auth status\`."
559
+ is_private="$(gql_repo_field "$REPO_Q" '.data.repository.isPrivate')"
560
+ discussions_on="$(gql_repo_field "$REPO_Q" '.data.repository.hasDiscussionsEnabled')"
561
+
562
+ if [[ "$is_private" == "false" ]]; then
563
+ success "Repository is public"
564
+ else
565
+ doctor_fail "Repository is PRIVATE — visitors cannot read the discussions"; failed=1
566
+ fi
567
+
568
+ if [[ "$discussions_on" == "true" ]]; then
569
+ success "Discussions are enabled"
570
+ else
571
+ doctor_fail "Discussions are NOT enabled (Settings → General → Features → Discussions)"; failed=1
572
+ fi
573
+
574
+ # --- 2. Config IDs must belong to THIS repo ----------------------------
575
+ local cfg_repo_id cfg_category_id cfg_enabled
576
+ cfg_repo_id="$(config_giscus_key 'data-repo-id')"
577
+ cfg_category_id="${CATEGORY_ID:-$(config_giscus_key 'data-category-id')}"
578
+ cfg_enabled="$(config_giscus_key 'enabled')"
579
+
580
+ if [[ "$cfg_enabled" == "true" ]]; then
581
+ success "_config.yml has giscus.enabled: true"
582
+ else
583
+ doctor_fail "_config.yml has giscus.enabled: '${cfg_enabled:-unset}' — comments will not render"
584
+ failed=1
585
+ fi
586
+
587
+ if [[ -z "$cfg_repo_id" ]]; then
588
+ doctor_fail "_config.yml is missing giscus.data-repo-id"; failed=1
589
+ elif [[ "$cfg_repo_id" == "$real_repo_id" ]]; then
590
+ success "data-repo-id matches $REPO_NWO ($real_repo_id)"
591
+ else
592
+ doctor_fail "data-repo-id '$cfg_repo_id' does NOT belong to $REPO_NWO (expected '$real_repo_id'). Forked without regenerating it?"
593
+ failed=1
594
+ fi
595
+
596
+ # Resolve the configured category id to a name (proves it exists on this repo).
597
+ local cat_name=""
598
+ if [[ -n "$cfg_category_id" ]]; then
599
+ cat_name="$(gql_repo_field "$REPO_Q" \
600
+ ".data.repository.discussionCategories.nodes[] | select(.id == \"${cfg_category_id}\") | .name")"
601
+ fi
602
+ if [[ -n "$cat_name" ]]; then
603
+ success "data-category-id resolves to the '$cat_name' category"
604
+ else
605
+ doctor_fail "data-category-id '${cfg_category_id:-unset}' is not a category on $REPO_NWO (see: $0 categories)"
606
+ failed=1
607
+ fi
608
+ [[ -n "$category_name" ]] && cat_name="$category_name"
609
+
610
+ # --- 3. The authoritative check: ask Giscus ----------------------------
611
+ # Only Giscus knows whether its GitHub App is installed on the repo. No
612
+ # amount of config inspection can substitute for this call.
613
+ local api_out
614
+ api_out="$(curl -s -m 20 -G https://giscus.app/api/discussions \
615
+ --data-urlencode "repo=$REPO_NWO" \
616
+ --data-urlencode "category=${cat_name:-Announcements}" \
617
+ --data-urlencode "term=/" \
618
+ -d "number=0&strict=true&first=1" 2>/dev/null || true)"
619
+
620
+ if [[ -z "$api_out" ]]; then
621
+ warn "Could not reach giscus.app (offline?) — app-installation check SKIPPED"
622
+ elif printf '%s' "$api_out" | grep -q 'giscus is not installed'; then
623
+ doctor_fail "The giscus GitHub App is NOT installed on $REPO_NWO — comments are broken sitewide."
624
+ info " Install: https://github.com/apps/giscus → Configure → grant access to $REPO_NWO"
625
+ failed=1
626
+ elif printf '%s' "$api_out" | grep -qi '"error"'; then
627
+ local msg
628
+ msg="$(printf '%s' "$api_out" | sed -E 's/.*"error"[[:space:]]*:[[:space:]]*"([^"]*)".*/\1/')"
629
+ # "Discussion not found" is the healthy answer for a page nobody has
630
+ # commented on yet — Giscus creates the thread on the first comment.
631
+ if printf '%s' "$msg" | grep -qi 'not found'; then
632
+ success "Giscus app is installed (no thread for '/' yet — created on first comment)"
633
+ else
634
+ doctor_fail "Giscus reported: $msg"; failed=1
635
+ fi
636
+ else
637
+ success "Giscus app is installed and serving this repo"
638
+ fi
639
+
640
+ echo
641
+ if [[ "$failed" -ne 0 ]]; then
642
+ error "Giscus chain is BROKEN — comments will not work for visitors."
643
+ fi
644
+ success "Giscus chain is healthy — comments will render for visitors."
645
+ }
646
+
451
647
  # ---------------------------------------------------------------------------
452
648
  # Usage / dispatch
453
649
  # ---------------------------------------------------------------------------
@@ -460,6 +656,7 @@ USAGE:
460
656
  ./scripts/bin/giscus-discussions <subcommand> [options]
461
657
 
462
658
  SUBCOMMANDS:
659
+ doctor Verify the whole chain works (app installed, IDs match)
463
660
  categories List discussion categories + node IDs (setup helper)
464
661
  list [--json] List discussions in the Giscus category
465
662
  thread <target> [--json] Show a discussion's full conversation
@@ -481,6 +678,7 @@ ENVIRONMENT:
481
678
  GISCUS_CATEGORY_ID Default --category-id
482
679
 
483
680
  EXAMPLES:
681
+ ./scripts/bin/giscus-discussions doctor
484
682
  ./scripts/bin/giscus-discussions categories
485
683
  ./scripts/bin/giscus-discussions list
486
684
  ./scripts/bin/giscus-discussions thread --page /posts/2025/01/21/remote-work-revolution/
@@ -497,6 +695,7 @@ main() {
497
695
  [[ $# -gt 0 ]] && shift || true
498
696
  case "$sub" in
499
697
  categories) cmd_categories "$@" ;;
698
+ doctor) cmd_doctor "$@" ;;
500
699
  list) cmd_list "$@" ;;
501
700
  thread) cmd_thread "$@" ;;
502
701
  draft) cmd_draft "$@" ;;