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
@@ -348,7 +348,26 @@ def check_quality(body, quality)
348
348
 
349
349
  # Bare URLs.
350
350
  if quality['flag_bare_urls']
351
- bare = strip_code_fences(body).scan(%r{(?<![("<\]])\bhttps?://[^\s)>\]]+}).reject { |u| u.include?('](') }
351
+ # Two sources of false positives this guards against:
352
+ # [http://x](http://x) — a CORRECT markdown link whose text is the URL.
353
+ # The lookbehind must exclude `[` too, or the link text is counted.
354
+ # <script>/<style> blocks and HTML attributes — pages that embed a config
355
+ # generator carry URLs in JS strings and markup, which are code, not prose.
356
+ # <code>http://…</code> — inline code in HTML syntax, which the markdown
357
+ # backtick form is already exempt from; the two should agree.
358
+ # Stripping markup in ONE pass leaves split or nested tags behind (CodeQL:
359
+ # incomplete multi-character sanitization). This is prose extraction for
360
+ # counting, never sanitization for output, but iterate to a fixpoint anyway
361
+ # so the result is exact. Each pass strictly shortens the string, so it
362
+ # terminates.
363
+ prose = strip_code_fences(body)
364
+ loop do
365
+ before = prose
366
+ prose = prose.gsub(%r{<(script|style|code|pre)\b[^>]*>.*?</\1\s*>}mi, ' ')
367
+ .gsub(/<[^<>]*>/, ' ')
368
+ break if prose == before
369
+ end
370
+ bare = prose.scan(%r{(?<![("<\[\]])\bhttps?://[^\s)>\]]+}).reject { |u| u.include?('](') }
352
371
  issues << Issue.new('info', 'quality', "#{bare.length} bare URL(s) — wrap as [text](url)") unless bare.empty?
353
372
  end
354
373
  issues
@@ -0,0 +1,170 @@
1
+ #!/usr/bin/env ruby
2
+ # Feature: ZER0-081
3
+ # frozen_string_literal: true
4
+
5
+ # ---------------------------------------------------------------------------
6
+ # design-system-check — token parity between the theme and its Claude Design
7
+ # mirror.
8
+ #
9
+ # The `--zer0-*` design tokens live twice on purpose:
10
+ # - `_sass/tokens/*.scss` — the source of truth the theme compiles
11
+ # - `_design-system/tokens/*.css` — the mirror pushed to the Claude Design
12
+ # project (see _design-system/SYNC.md)
13
+ #
14
+ # This check keeps the two in lockstep so the design system can be built from
15
+ # either side without silent drift:
16
+ #
17
+ # 1. Per file pair: the sets of `--zer0-*` names must match, and every value
18
+ # must resolve to the same string. `var(--x, fallback)` wrappers are
19
+ # resolved against the mirror's concrete `--bs-*`/`--bd-*` primitives, so
20
+ # the theme's alias form and the mirror's self-contained form compare
21
+ # equal.
22
+ # 2. Skins: `_sass/theme/_skins.scss` and `tokens/skins.css` must declare the
23
+ # same skin names, and each mirror skin's brand hex must appear in the
24
+ # matching SCSS block.
25
+ # 3. Dangling-token audit: any fallback-less `var(--zer0-…)` reference in
26
+ # `_sass/` must be declared somewhere (tokens, includes, or JS).
27
+ #
28
+ # ruby scripts/design-system-check.rb # exit 1 + report on drift
29
+ #
30
+ # Runs in the `core` test suite (test/test_core.sh). Plain Ruby, no gems.
31
+ # ---------------------------------------------------------------------------
32
+
33
+ ROOT = File.expand_path('..', __dir__)
34
+
35
+ PAIRS = {
36
+ '_sass/tokens/_color.scss' => '_design-system/tokens/colors.css',
37
+ '_sass/tokens/_spacing.scss' => '_design-system/tokens/spacing.css',
38
+ '_sass/tokens/_radius.scss' => '_design-system/tokens/radius.css',
39
+ '_sass/tokens/_typography.scss' => '_design-system/tokens/typography.css',
40
+ '_sass/tokens/_shadow.scss' => '_design-system/tokens/shadows.css',
41
+ '_sass/tokens/_motion.scss' => '_design-system/tokens/motion.css',
42
+ '_sass/tokens/_breakpoints.scss' => '_design-system/tokens/breakpoints.css',
43
+ '_sass/tokens/_layers.scss' => '_design-system/tokens/layers.css'
44
+ }.freeze
45
+
46
+ SKIN_SCSS = '_sass/theme/_skins.scss'
47
+ SKIN_CSS = '_design-system/tokens/skins.css'
48
+
49
+ def read(rel)
50
+ path = File.join(ROOT, rel)
51
+ abort "design-system-check: missing file #{rel}" unless File.file?(path)
52
+ File.read(path)
53
+ end
54
+
55
+ # Strip /* */ blocks, // line comments, and everything from the first @media on
56
+ # (motion files re-declare durations under prefers-reduced-motion).
57
+ def strip(text, scss:)
58
+ t = text.split('@media', 2).first
59
+ t = t.gsub(%r{/\*.*?\*/}m, '')
60
+ t = t.gsub(%r{(?:^|\s)//.*$}, '') if scss
61
+ t
62
+ end
63
+
64
+ # { '--zer0-foo' => 'raw value', ... } from declaration-position matches.
65
+ def declarations(text)
66
+ text.scan(/(--zer0-[\w-]+)\s*:\s*([^;]+);/).each_with_object({}) do |(k, v), h|
67
+ h[k] ||= v.strip.gsub(/\s+/, ' ')
68
+ end
69
+ end
70
+
71
+ # All custom properties (any prefix) declared in the mirror's colors.css light
72
+ # scope — the concrete primitives var() chains resolve against.
73
+ def primitives
74
+ light = read(SKIN_CSS.sub('skins', 'colors')).split('[data-bs-theme="dark"]', 2).first
75
+ strip(light, scss: false).scan(/(--[\w-]+)\s*:\s*([^;]+);/)
76
+ .each_with_object({}) { |(k, v), h| h[k] ||= v.strip.gsub(/\s+/, ' ') }
77
+ end
78
+
79
+ # Resolve a value that is entirely a var() wrapper: prefer the primitive's
80
+ # concrete value, else the wrapper's own fallback. Inner var() references in
81
+ # composite values (color-mix, rgba) are left as-is and compared literally.
82
+ def resolve(value, prims, depth = 0)
83
+ v = value.strip.gsub(/\s+/, ' ')
84
+ return v if depth > 6
85
+
86
+ m = v.match(/\Avar\((--[\w-]+)(?:\s*,\s*(.+))?\)\z/)
87
+ return v unless m
88
+
89
+ name, fallback = m[1], m[2]
90
+ if prims.key?(name)
91
+ resolve(prims[name], prims, depth + 1)
92
+ elsif fallback
93
+ resolve(fallback, prims, depth + 1)
94
+ else
95
+ v
96
+ end
97
+ end
98
+
99
+ failures = []
100
+ prims = primitives
101
+
102
+ # --- 1. token parity per file pair -----------------------------------------
103
+ PAIRS.each do |scss_rel, css_rel|
104
+ scss = declarations(strip(read(scss_rel), scss: true))
105
+ css = declarations(strip(read(css_rel), scss: false))
106
+
107
+ (scss.keys - css.keys).each { |k| failures << "#{css_rel}: missing #{k} (declared in #{scss_rel})" }
108
+ (css.keys - scss.keys).each { |k| failures << "#{scss_rel}: missing #{k} (declared in #{css_rel})" }
109
+
110
+ (scss.keys & css.keys).each do |k|
111
+ a = resolve(scss[k], prims)
112
+ b = resolve(css[k], prims)
113
+ failures << "#{k}: theme resolves to `#{a}` but mirror to `#{b}`" unless a == b
114
+ end
115
+ end
116
+
117
+ # Both motion files must keep the reduced-motion guard.
118
+ [PAIRS.key('_design-system/tokens/motion.css'), '_design-system/tokens/motion.css'].each do |rel|
119
+ failures << "#{rel}: lost its prefers-reduced-motion guard" unless read(rel).include?('prefers-reduced-motion')
120
+ end
121
+
122
+ # --- 2. skins: same names, same brand hex ----------------------------------
123
+ scss_skins_src = read(SKIN_SCSS)
124
+ css_skins_src = read(SKIN_CSS)
125
+ scss_skins = scss_skins_src.scan(/\[data-theme-skin="(\w+)"\]/).flatten.uniq.sort
126
+ css_skins = css_skins_src.scan(/\[data-theme-skin="(\w+)"\]/).flatten.uniq.sort
127
+ unless scss_skins == css_skins
128
+ failures << "skins differ: theme has #{scss_skins.join(', ')} but mirror has #{css_skins.join(', ')}"
129
+ end
130
+
131
+ (scss_skins & css_skins).each do |skin|
132
+ block = css_skins_src[/\[data-theme-skin="#{skin}"\]\s*\{.*?\}/m].to_s
133
+ hex = block[/--zer0-color-primary:\s*(#\h+)/, 1]
134
+ next unless hex
135
+
136
+ scss_block = scss_skins_src[/\[data-theme-skin="#{skin}"\]\s*\{.*?\}/m].to_s
137
+ unless scss_block.downcase.include?(hex.downcase)
138
+ failures << "skin #{skin}: mirror brand #{hex} not found in #{SKIN_SCSS}"
139
+ end
140
+ end
141
+
142
+ # --- 3. dangling-token audit ------------------------------------------------
143
+ declared = Dir.glob(File.join(ROOT, '_sass/**/*.scss'))
144
+ .flat_map { |f| File.read(f).scan(/(--zer0-[\w-]+)\s*:/) }.flatten
145
+ declared += Dir.glob(File.join(ROOT, '{_includes,_layouts}/**/*.html'))
146
+ .flat_map { |f| File.read(f).scan(/(--zer0-[\w-]+)\s*:/) }.flatten
147
+ declared += Dir.glob(File.join(ROOT, 'assets/js/**/*.js')).flat_map do |f|
148
+ src = File.read(f)
149
+ src.scan(/setProperty\(\s*['"](--zer0-[\w-]+)/).flatten +
150
+ src.scan(/['"`](--zer0-[\w-]+)['"`:]/).flatten
151
+ end
152
+ declared = declared.uniq
153
+
154
+ Dir.glob(File.join(ROOT, '_sass/**/*.scss')).sort.each do |f|
155
+ File.read(f).scan(/var\(\s*(--zer0-[\w-]+)\s*\)/).flatten.uniq.each do |token|
156
+ next if declared.include?(token)
157
+
158
+ failures << "#{f.sub("#{ROOT}/", '')}: references #{token} with no fallback, but it is never declared"
159
+ end
160
+ end
161
+
162
+ # --- report -----------------------------------------------------------------
163
+ if failures.empty?
164
+ puts "design-system-check: OK — #{PAIRS.size} token file pairs, #{css_skins.size} skins, no dangling tokens"
165
+ else
166
+ warn "design-system-check: #{failures.size} problem(s) — theme (_sass/tokens) and mirror (_design-system/tokens) have drifted:"
167
+ failures.each { |f| warn " - #{f}" }
168
+ warn 'Fix the drift (see _design-system/SYNC.md), then re-run: ruby scripts/design-system-check.rb'
169
+ exit 1
170
+ end
data/scripts/lib/audit.sh CHANGED
@@ -205,13 +205,45 @@ detect_consumer_mode() {
205
205
  return
206
206
  fi
207
207
 
208
- if grep -qE '^remote_theme:' "$config"; then
208
+ # Match a top-level `remote_theme` key. The whitespace class before the
209
+ # colon is load-bearing: most fleet consumers write column-aligned YAML
210
+ # (`remote_theme : "bamr87/zer0-mistakes"`), which a bare
211
+ # `^remote_theme:` never matches — every one of them was silently
212
+ # misreported as gem mode, skipping the plugin checks entirely.
213
+ # `|| true` keeps a no-match grep from tripping the caller's `set -o pipefail`.
214
+ local line value
215
+ line=$(grep -m1 -E '^remote_theme[[:space:]]*:' "$config" 2>/dev/null || true)
216
+ value=$(printf '%s' "$line" \
217
+ | sed -E 's/^remote_theme[[:space:]]*:[[:space:]]*//; s/[[:space:]]*(#.*)?$//; s/^"(.*)"$/\1/; s/^'\''(.*)'\''$/\1/')
218
+
219
+ # `remote_theme: false` (or an empty value) is how a consumer turns the
220
+ # remote theme OFF and resolves the theme as a gem instead.
221
+ if [[ -n "$value" && "$value" != "false" ]]; then
209
222
  echo "remote_theme"
210
223
  else
211
224
  echo "gem"
212
225
  fi
213
226
  }
214
227
 
228
+ # detect_consumer_source <consumer_root>
229
+ # Echoes the Jekyll `source:` value from _config.yml (relative path), or empty.
230
+ #
231
+ # Jekyll's `source:` relocates the whole site tree — zer0-pages keeps every
232
+ # theme-shadowing file under `pages/`. Auditing the repo root there reports a
233
+ # falsely clean "0 overrides, 0 unique files"; the real surface only appears
234
+ # when the scan follows `source:`.
235
+ detect_consumer_source() {
236
+ local consumer_root="$1"
237
+ local config="$consumer_root/_config.yml"
238
+
239
+ [[ -f "$config" ]] || return 0
240
+
241
+ local line
242
+ line=$(grep -m1 -E '^source[[:space:]]*:' "$config" 2>/dev/null || true)
243
+ printf '%s' "$line" \
244
+ | sed -E 's/^source[[:space:]]*:[[:space:]]*//; s/[[:space:]]*(#.*)?$//; s/^"(.*)"$/\1/; s/^'\''(.*)'\''$/\1/; s#^\./##; s#/$##'
245
+ }
246
+
215
247
  # ---------------------------------------------------------------------------
216
248
  # Output helpers
217
249
  # ---------------------------------------------------------------------------
@@ -277,7 +309,15 @@ print_classification() {
277
309
  echo "::notice file=$relpath::OPTIONAL_PLUGIN (not required)" ;;
278
310
  esac
279
311
  elif [[ "$format" == "json" ]]; then
280
- echo " {\"path\":\"$relpath\",\"status\":\"$classification\"},"
312
+ # Separator goes BEFORE each entry but the first, so the array never
313
+ # ends on a dangling comma. Emitting a trailing comma per entry (the
314
+ # previous behaviour) produced `...},\n]}` — which no JSON parser
315
+ # accepts, making --format json unusable for any CI consumer.
316
+ if [[ "${JSON_ENTRY_EMITTED:-0}" == "1" ]]; then
317
+ printf ',\n'
318
+ fi
319
+ JSON_ENTRY_EMITTED=1
320
+ printf ' {"path":"%s","status":"%s"}' "$relpath" "$classification"
281
321
  else
282
322
  echo -e "${color}[$classification]${NC} $relpath"
283
323
  fi
@@ -0,0 +1,137 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # ---------------------------------------------------------------------------
5
+ # lint-liquid-raw — guard the Markdown the Pages build renders through Liquid
6
+ #
7
+ # `github-pages` ships jekyll-optional-front-matter, so EVERY non-excluded
8
+ # Markdown file in the repo becomes a page — CHANGELOG.md and the
9
+ # _design-system/ mirror included — and every one of them is run through Liquid
10
+ # before Markdown. Two failure modes follow, and neither is loud:
11
+ #
12
+ # 1. An unbalanced `{% raw %}`. Liquid's raw block swallows everything up to
13
+ # the FIRST `{% endraw %}`, so a stray opener silently eats the rest of the
14
+ # document. CHANGELOG.md carried one that consumed 945 lines while the
15
+ # build stayed green.
16
+ #
17
+ # 2. A literal `{% raw %}` / `{% endraw %}` written as documentation prose.
18
+ # It reads like text but parses as a tag, which is how (1) happens. To show
19
+ # the tag, escape it: `{% raw %}{% raw %}{% endraw %}`.
20
+ #
21
+ # A Liquid *syntax* error (e.g. `{{…}}`) is caught by the Pages workflow, which
22
+ # fails on "Liquid Warning" in the build output. This script catches the class
23
+ # the build cannot report, because swallowed text is not an error to Liquid.
24
+ #
25
+ # Usage:
26
+ # scripts/lint-liquid-raw.rb # scan the rendered Markdown surface
27
+ # scripts/lint-liquid-raw.rb PATH... # scan specific files
28
+ # scripts/lint-liquid-raw.rb --verbose # list every file scanned
29
+ # ---------------------------------------------------------------------------
30
+
31
+ ROOT = File.expand_path('..', __dir__)
32
+ TAG = /\{%-?\s*(raw|endraw)\s*-?%\}/.freeze
33
+
34
+ # The escape form: a raw block whose entire body is one raw/endraw tag, which
35
+ # is how you render the tag as text. Blanked before scanning so the literal it
36
+ # carries is not mistaken for a real tag.
37
+ ESCAPED = /\{%-?\s*raw\s*-?%\}\{%-?\s*(?:raw|endraw)\s*-?%\}\{%-?\s*endraw\s*-?%\}/.freeze
38
+
39
+ # Directories Jekyll never reads, plus the ones _config.yml excludes. Kept in
40
+ # sync with the `exclude:` list; a path missing here only costs a false
41
+ # positive, never a missed one.
42
+ SKIP_DIRS = %w[
43
+ .git .github .claude .cursor .devcontainer .frontmatter .vscode .obsidian
44
+ .jekyll-cache .sass-cache _site node_modules vendor pkg logs
45
+ docs scripts templates test tests examples submodules gemfiles docker lib
46
+ ].freeze
47
+
48
+ # True when `pos` sits inside a single-backtick code span: an odd number of
49
+ # backtick runs precede it on its own line.
50
+ def in_code_span?(text, pos)
51
+ bol = text.rindex("\n", pos - 1)
52
+ line = text[(bol ? bol + 1 : 0)...pos]
53
+ line.scan(/`+/).size.odd?
54
+ end
55
+
56
+ def scan(path)
57
+ # Blank the escape form (keeping byte offsets and newlines) so the literal
58
+ # tag it renders is not read as a tag itself.
59
+ text = File.read(path, encoding: 'UTF-8')
60
+ .gsub(ESCAPED) { |m| ' ' * m.length }
61
+ problems = []
62
+ in_raw = false
63
+ opened_at = nil
64
+ opened_in_span = false
65
+
66
+ text.to_enum(:scan, TAG).each do
67
+ match = Regexp.last_match
68
+ line = text[0, match.begin(0)].count("\n") + 1
69
+ if in_raw
70
+ # Inside a raw block every tag is literal text — nothing to check.
71
+ if match[1] == 'endraw'
72
+ if opened_in_span && line != opened_at
73
+ problems << "#{path}:#{opened_at}: `{% raw %}` inside a code span " \
74
+ "opens a block that only closes at line #{line} — " \
75
+ 'escape it to show the tag as text'
76
+ end
77
+ in_raw = false
78
+ opened_at = nil
79
+ opened_in_span = false
80
+ end
81
+ next
82
+ end
83
+
84
+ if match[1] == 'raw'
85
+ in_raw = true
86
+ opened_at = line
87
+ # `{% raw %}` inside an inline code span is the documentation-mention
88
+ # trap: it reads as text but opens a real block. Legitimate inline use
89
+ # (`{% raw %}{{ x }}{% endraw %}`) closes on the same line; a mention
90
+ # does not, and swallows everything up to the next `{% endraw %}`.
91
+ opened_in_span = in_code_span?(text, match.begin(0))
92
+ else
93
+ problems << "#{path}:#{line}: `{% endraw %}` with no matching `{% raw %}`"
94
+ end
95
+ end
96
+
97
+ if in_raw
98
+ problems << "#{path}:#{opened_at}: `{% raw %}` is never closed — " \
99
+ 'Liquid will swallow the rest of the file'
100
+ end
101
+ problems
102
+ rescue ArgumentError, Encoding::InvalidByteSequenceError => e
103
+ ["#{path}: could not read as UTF-8 (#{e.class})"]
104
+ end
105
+
106
+ def markdown_files
107
+ Dir.chdir(ROOT) do
108
+ Dir.glob('**/*.{md,markdown}', File::FNM_DOTMATCH).reject do |p|
109
+ p.split(File::SEPARATOR).any? { |seg| SKIP_DIRS.include?(seg) }
110
+ end.sort
111
+ end
112
+ end
113
+
114
+ verbose = ARGV.delete('--verbose')
115
+ targets = ARGV.empty? ? markdown_files : ARGV
116
+
117
+ problems = []
118
+ Dir.chdir(ROOT) do
119
+ targets.each do |path|
120
+ next unless File.file?(path)
121
+
122
+ warn " scanning #{path}" if verbose
123
+ problems.concat(scan(path))
124
+ end
125
+ end
126
+
127
+ if problems.empty?
128
+ puts "✓ lint-liquid-raw: #{targets.size} Markdown files, all " \
129
+ '`{% raw %}` blocks balanced'
130
+ exit 0
131
+ end
132
+
133
+ warn '✗ lint-liquid-raw: unbalanced Liquid raw blocks'
134
+ problems.each { |p| warn " #{p}" }
135
+ warn ''
136
+ warn 'To display a literal tag, escape it: `{% raw %}{% raw %}{% endraw %}`'
137
+ exit 1
@@ -142,15 +142,16 @@ test_mermaid_init() {
142
142
  local url="$1"
143
143
  local description="$2"
144
144
 
145
- run_test "$description" "curl -s '$url' | grep -q 'mermaid.initialize'"
145
+ run_test "$description" "curl -s '$url' | grep -q 'mermaid-diagrams.js'"
146
146
  }
147
147
 
148
- # Test diagram rendering
148
+ # Test diagram presence (a ```mermaid fence is served as code.language-mermaid;
149
+ # the deferred script turns it into a figure in the browser)
149
150
  test_diagram_rendering() {
150
151
  local url="$1"
151
152
  local description="$2"
152
-
153
- run_test "$description" "curl -s '$url' | grep -q 'class=\"mermaid\"'"
153
+
154
+ run_test "$description" "curl -s '$url' | grep -q 'language-mermaid'"
154
155
  }
155
156
 
156
157
  # Main test execution
@@ -179,16 +180,29 @@ main() {
179
180
  # Mermaid include file tests
180
181
  log_info "Testing Mermaid include file..."
181
182
 
182
- test_file_content "_includes/components/mermaid.html" "site.mermaid.src" "Mermaid script from _config (local vendor path)"
183
- test_file_content "_includes/components/mermaid.html" "mermaid.initialize" "Mermaid initialization script"
184
- test_file_content "_includes/components/mermaid.html" "data-bs-theme" "Bootstrap theme detection configured"
185
- test_file_content "_includes/components/mermaid.html" "getMermaidConfig" "Adaptive light/dark theming configured"
183
+ test_file_content "_includes/components/mermaid.html" "mm.src" "Mermaid script from _config (local vendor path)"
184
+ test_file_content "_includes/components/mermaid.html" "mermaidConfig" "Diagram config block emitted for the behaviour script"
185
+ test_file_content "_includes/components/mermaid.html" "mermaid-diagrams.js" "Behaviour script loaded after the vendor bundle"
186
+ test_file_content "_includes/components/mermaid.html" "defer" "Scripts are deferred (never block parsing)"
187
+
188
+ # Behaviour script (assets/js/mermaid-diagrams.js)
189
+ log_info "Testing diagram behaviour script..."
190
+
191
+ test_file_exists "assets/js/mermaid-diagrams.js" "Diagram behaviour script exists"
192
+ test_file_content "assets/js/mermaid-diagrams.js" "mermaid.initialize" "Mermaid initialization"
193
+ test_file_content "assets/js/mermaid-diagrams.js" "data-bs-theme" "Colour-mode change observed for re-render"
194
+ test_file_content "assets/js/mermaid-diagrams.js" "data-theme-skin" "Skin change observed for re-render"
195
+ test_file_content "assets/js/mermaid-diagrams.js" "themeVariables" "Palette derived into Mermaid theme variables"
196
+ test_file_content "assets/js/mermaid-diagrams.js" "zer0Mermaid" "Public API exposed"
197
+ test_file_exists "_sass/components/_mermaid.scss" "Diagram figure styles exist"
198
+ test_file_content "assets/css/main.scss" "components/mermaid" "Diagram styles imported by the stylesheet manifest"
186
199
 
187
200
  # Documentation tests
188
201
  log_info "Testing documentation..."
189
202
 
190
203
  test_file_content "pages/_docs/features/mermaid-diagrams.md" "mermaid: true" "Main docs have front matter"
191
204
  test_file_content "pages/_docs/features/mermaid-diagrams.md" "graph TD" "Main docs have examples"
205
+ test_file_content "pages/_docs/features/mermaid-diagrams.md" "accTitle" "Main docs cover accessible captions"
192
206
 
193
207
  # Server tests (if not quick mode)
194
208
  if [ "$QUICK" = false ]; then
@@ -131,7 +131,9 @@ 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
+ source "$TEST_DIR/test_visual_evidence_autogen.sh"
136
+
135
137
  # Summary
136
138
  echo -e "\n${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
137
139
  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
@@ -0,0 +1,24 @@
1
+ #!/bin/bash
2
+
3
+ # Bridge for scripts/ci/test_visual_evidence_autogen.py (ZER0-085).
4
+ #
5
+ # The planner/decider behind visual-evidence-autogen.yml lives in
6
+ # scripts/ci/visual_evidence_autogen.py, next to the other CI helpers, and its
7
+ # tests are Python beside it. This wrapper puts them on the CI path: run_tests.sh
8
+ # sources this file, and ./scripts/bin/test runs run_tests.sh on every PR — the
9
+ # same arrangement as test_agent_review_result.sh, for the same reason (a test
10
+ # suite no runner invokes rots silently).
11
+
12
+ VEA_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
13
+
14
+ print_suite_header "Visual-evidence autogen planner (scripts/ci/visual_evidence_autogen.py)"
15
+
16
+ if command -v python3 >/dev/null 2>&1; then
17
+ vea_output="$(python3 "$VEA_ROOT/scripts/ci/test_visual_evidence_autogen.py" 2>&1)"
18
+ vea_status=$?
19
+ echo "$vea_output"
20
+ assert_equals "0" "$vea_status" \
21
+ "scripts/ci/test_visual_evidence_autogen.py passes (plan/decide/contract guards)"
22
+ else
23
+ echo "python3 not available — skipping the visual-evidence autogen tests"
24
+ fi