jekyll-theme-zer0 1.27.0 → 1.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +812 -13
  3. data/_data/backlog.yml +356 -2
  4. data/_data/consumers.yml +252 -0
  5. data/_data/features.yml +234 -5
  6. data/_data/i18n/fr.yml +103 -0
  7. data/_data/i18n/manifest.yml +1529 -0
  8. data/_data/ingredient_densities.yml +122 -0
  9. data/_data/navigation/docs.yml +2 -0
  10. data/_data/navigation/main.yml +22 -0
  11. data/_data/recipe_courses.yml +64 -0
  12. data/_data/series.yml +19 -0
  13. data/_data/theme-manifest.yml +387 -277
  14. data/_data/ui-text.yml +8 -0
  15. data/_includes/README.md +36 -3
  16. data/_includes/analytics/google-tag-manager-body.html +11 -2
  17. data/_includes/analytics/google-tag-manager-head.html +16 -6
  18. data/_includes/analytics/posthog.html +33 -4
  19. data/_includes/components/abc-letter.html +43 -0
  20. data/_includes/components/background-customizer.html +2 -2
  21. data/_includes/components/book-card.html +42 -0
  22. data/_includes/components/book-nav.html +80 -0
  23. data/_includes/components/book-plate.html +31 -0
  24. data/_includes/components/book-toc.html +47 -0
  25. data/_includes/components/bookshelf.html +68 -0
  26. data/_includes/components/card-grid.html +60 -0
  27. data/_includes/components/cookie-consent.html +4 -4
  28. data/_includes/components/data-card.html +95 -0
  29. data/_includes/components/halfmoon.html +5 -1
  30. data/_includes/components/info-section.html +8 -3
  31. data/_includes/components/language-toggle.html +168 -21
  32. data/_includes/components/page-feedback.html +65 -2
  33. data/_includes/components/page-views-init.html +55 -0
  34. data/_includes/components/page-views.html +33 -0
  35. data/_includes/components/recipe-card.html +67 -0
  36. data/_includes/components/recipe-duration.html +50 -0
  37. data/_includes/components/recipe-grams.html +58 -0
  38. data/_includes/components/recipe-index.html +96 -0
  39. data/_includes/components/recipe-ingredients.html +90 -0
  40. data/_includes/components/recipe-meta.html +96 -0
  41. data/_includes/components/recipe-nutrition.html +57 -0
  42. data/_includes/components/recipe-qty.html +73 -0
  43. data/_includes/components/recipe-ratio.html +151 -0
  44. data/_includes/components/recipe-scaler.html +73 -0
  45. data/_includes/components/recipe-steps.html +86 -0
  46. data/_includes/components/recipe-temp.html +45 -0
  47. data/_includes/components/search-modal.html +2 -2
  48. data/_includes/components/shortcuts-modal.html +3 -0
  49. data/_includes/components/theme-controls-bar.html +10 -2
  50. data/_includes/components/theme-customizer.html +8 -2
  51. data/_includes/components/theme-info.html +8 -1
  52. data/_includes/content/jsonld-software.html +22 -3
  53. data/_includes/content/seo.html +9 -3
  54. data/_includes/core/color-mode-init.html +13 -4
  55. data/_includes/core/favicon.html +46 -0
  56. data/_includes/core/footer.html +18 -16
  57. data/_includes/core/head.html +21 -0
  58. data/_includes/core/header.html +7 -4
  59. data/_includes/custom/body-end.html +18 -0
  60. data/_includes/custom/body-start.html +17 -0
  61. data/_includes/custom/footer.html +18 -0
  62. data/_includes/custom/head.html +18 -0
  63. data/_includes/navigation/local-graph.html +28 -2
  64. data/_includes/navigation/nav-tree.html +3 -3
  65. data/_includes/navigation/section-sidebar.html +93 -11
  66. data/_includes/navigation/sidebar-config.html +21 -0
  67. data/_includes/navigation/sidebar-left.html +2 -1
  68. data/_includes/navigation/sidebar-nav.html +4 -0
  69. data/_includes/navigation/sidebar-pagetree.html +150 -0
  70. data/_includes/navigation/sidebar-right.html +2 -1
  71. data/_includes/navigation/unified-drawer.html +8 -2
  72. data/_includes/obsidian/full-graph.html +165 -136
  73. data/_includes/setup/wizard.html +173 -86
  74. data/_includes/stats/stats-metrics.html +2 -2
  75. data/_layouts/404.html +260 -0
  76. data/_layouts/README.md +4 -0
  77. data/_layouts/admin.html +2 -2
  78. data/_layouts/article.html +5 -0
  79. data/_layouts/book-abc.html +106 -0
  80. data/_layouts/book-story.html +91 -0
  81. data/_layouts/book.html +113 -0
  82. data/_layouts/collection.html +17 -5
  83. data/_layouts/cookbook.html +88 -0
  84. data/_layouts/default.html +4 -4
  85. data/_layouts/home.html +5 -2
  86. data/_layouts/landing.html +9 -0
  87. data/_layouts/news.html +155 -31
  88. data/_layouts/recipe.html +274 -0
  89. data/_layouts/root.html +38 -5
  90. data/_layouts/section.html +96 -25
  91. data/_sass/components/_book.scss +423 -0
  92. data/_sass/components/_callout.scss +1 -1
  93. data/_sass/components/_footer.scss +37 -1
  94. data/_sass/components/_page-views.scss +36 -0
  95. data/_sass/components/_recipe.scss +506 -0
  96. data/_sass/components/_setup-wizard.scss +235 -0
  97. data/_sass/components/_ui-enhancements.scss +6 -6
  98. data/_sass/core/_navbar.scss +263 -15
  99. data/_sass/core/_obsidian.scss +286 -6
  100. data/_sass/layouts/_landing.scss +2 -2
  101. data/_sass/theme/_backgrounds.scss +21 -8
  102. data/_sass/tokens/_color.scss +6 -0
  103. data/_sass/tokens/_index.scss +2 -0
  104. data/_sass/tokens/_radius.scss +21 -0
  105. data/_sass/tokens/_typography.scss +4 -0
  106. data/_sass/utilities/_focus.scss +14 -0
  107. data/assets/css/main.scss +4 -0
  108. data/assets/js/auto-hide-nav.js +5 -1
  109. data/assets/js/halfmoon.js +26 -0
  110. data/assets/js/modules/navigation/navbar.js +55 -0
  111. data/assets/js/obsidian-graph.js +702 -264
  112. data/assets/js/obsidian-local-graph.js +161 -54
  113. data/assets/js/page-views.js +372 -0
  114. data/assets/js/recipe-scaler.js +501 -0
  115. data/assets/js/search-modal.js +14 -1
  116. data/assets/js/setup-wizard.js +322 -20
  117. data/scripts/README.md +29 -0
  118. data/scripts/bin/audit-consumer +39 -7
  119. data/scripts/bin/giscus-discussions +213 -14
  120. data/scripts/bin/manifest +66 -16
  121. data/scripts/bin/validate +5 -1
  122. data/scripts/ci/agent_review_result.py +164 -0
  123. data/scripts/ci/test_agent_review_result.py +172 -0
  124. data/scripts/design-system-check.rb +170 -0
  125. data/scripts/install/README.md +47 -6
  126. data/scripts/install/ai/client.sh +302 -93
  127. data/scripts/install/ai/prompts/spec.schema.json +1 -1
  128. data/scripts/install/ai/wizard.sh +10 -5
  129. data/scripts/install/apply.sh +7 -3
  130. data/scripts/install/cli.sh +54 -4
  131. data/scripts/install/config.sh +167 -0
  132. data/scripts/install/doctor.sh +38 -0
  133. data/scripts/install/plan.sh +10 -0
  134. data/scripts/install/spec.sh +15 -7
  135. data/scripts/install/template.sh +4 -0
  136. data/scripts/lib/audit.sh +42 -2
  137. data/scripts/lint-liquid-raw.rb +137 -0
  138. data/scripts/propagate.rb +277 -0
  139. data/scripts/test/lib/run_tests.sh +2 -1
  140. data/scripts/test/lib/test_agent_review_result.sh +27 -0
  141. data/scripts/translate.rb +161 -15
  142. metadata +56 -2
@@ -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 "$@" ;;
data/scripts/bin/manifest CHANGED
@@ -35,12 +35,15 @@ DESCRIPTION:
35
35
  Run automatically by scripts/bin/release before tagging.
36
36
 
37
37
  OPTIONS:
38
+ --check Exit non-zero if the committed manifest is stale; write
39
+ nothing. `generated_at` is ignored in the comparison.
38
40
  --dry-run Print manifest to stdout; don't write the file
39
41
  --verbose Show each file being processed
40
42
  --help, -h Show this help message
41
43
 
42
44
  EXAMPLES:
43
45
  ./scripts/bin/manifest # Regenerate _data/theme-manifest.yml
46
+ ./scripts/bin/manifest --check # CI gate: is the manifest current?
44
47
  ./scripts/bin/manifest --dry-run # Preview output
45
48
  ./scripts/bin/manifest --verbose # Show every file being hashed
46
49
  EOF
@@ -64,26 +67,49 @@ THEME_DATA_PATHS=(
64
67
  "_data/ui-text.yml"
65
68
  )
66
69
 
67
- # Plugins that remote_theme consumers MUST vendor locally (GitHub Pages won't
68
- # load plugins from a remote theme).
69
- REQUIRED_PLUGIN_PATHS=(
70
- "_plugins/obsidian_links.rb"
71
- )
70
+ # Plugins that remote_theme consumers MUST vendor locally.
71
+ #
72
+ # Deliberately empty. Nothing the theme ships is mandatory for a stock
73
+ # remote_theme consumer: GitHub Pages builds in safe mode, so it does not load
74
+ # ANY local plugin — vendoring one there is inert, not required.
75
+ REQUIRED_PLUGIN_PATHS=()
72
76
 
73
- # Optional plugins that consumers can vendor for enhanced functionality.
77
+ # Optional plugins a consumer can vendor for enhanced functionality. These only
78
+ # execute on a self-built (vanilla Jekyll) site, never on default GitHub Pages.
79
+ #
80
+ # `obsidian_links.rb` sits here, not above, because obsidian.instructions.md is
81
+ # explicit that it is opt-in ("skipped under the github-pages gem"; "an
82
+ # SEO-quality enhancement for forks that self-build") and that the Liquid + JS
83
+ # path is the primary one. Listing it as required made the audit demand a file
84
+ # that cannot run, flagging MISSING_PLUGIN on seven fleet consumers that have
85
+ # no Obsidian content at all.
74
86
  OPTIONAL_PLUGIN_PATHS=(
87
+ "_plugins/obsidian_links.rb"
75
88
  "_plugins/admin_page_urls.rb"
76
89
  "_plugins/content_statistics_generator.rb"
77
90
  "_plugins/theme_version.rb"
78
91
  )
79
92
 
93
+ # Emit YAML list items, or an empty flow sequence when there are none.
94
+ # `printf ' - %s\n'` with zero args would otherwise emit a bare ` - `, i.e. a
95
+ # phantom empty entry in the list.
96
+ yaml_list_items() {
97
+ if [[ $# -eq 0 ]]; then
98
+ echo " []"
99
+ else
100
+ printf ' - %s\n' "$@"
101
+ fi
102
+ }
103
+
80
104
  # ---------------------------------------------------------------------------
81
105
  # Argument parsing
82
106
  DRY_RUN=false
83
107
  VERBOSE=false
108
+ CHECK=false
84
109
 
85
110
  while [[ $# -gt 0 ]]; do
86
111
  case $1 in
112
+ --check) CHECK=true; DRY_RUN=true ;;
87
113
  --dry-run) DRY_RUN=true ;;
88
114
  --verbose) VERBOSE=true ;;
89
115
  --help|-h) show_usage; exit 0 ;;
@@ -93,7 +119,14 @@ while [[ $# -gt 0 ]]; do
93
119
  done
94
120
 
95
121
  # ---------------------------------------------------------------------------
96
- step "Generating theme manifest..."
122
+ # Under --dry-run stdout carries only the manifest YAML — the CI freshness gate
123
+ # (ci.yml "Theme manifest freshness") diffs that stdout straight against the
124
+ # committed file, so progress logs must not pollute it.
125
+ if [[ "$DRY_RUN" == "true" ]]; then
126
+ step "Generating theme manifest..." >&2
127
+ else
128
+ step "Generating theme manifest..."
129
+ fi
97
130
  cd "$REPO_ROOT"
98
131
 
99
132
  CURRENT_VERSION=$(get_current_version)
@@ -141,17 +174,19 @@ $(printf ' - %s\n' "${THEMABLE_PATHS[@]}")
141
174
  theme_data_paths:
142
175
  $(printf ' - %s\n' "${THEME_DATA_PATHS[@]}")
143
176
 
144
- # Plugin files that consumers using remote_theme MUST vendor locally.
145
- # GitHub Pages does not load plugins from a remote_theme source.
177
+ # Plugin files a consumer MUST vendor locally. Empty by design: GitHub Pages
178
+ # builds in safe mode and loads no local plugins at all, so nothing the theme
179
+ # ships is mandatory for a stock remote_theme consumer.
146
180
  required_plugin_paths:
147
- $(printf ' - %s\n' "${REQUIRED_PLUGIN_PATHS[@]}")
148
- # Optional plugins consumers can vendor for enhanced functionality.
181
+ $(yaml_list_items ${REQUIRED_PLUGIN_PATHS[@]+"${REQUIRED_PLUGIN_PATHS[@]}"})
182
+ # Optional plugins consumers can vendor for enhanced functionality. These run
183
+ # only on self-built (vanilla Jekyll) sites, never on default GitHub Pages.
149
184
  optional_plugin_paths:
150
- $(printf ' - %s\n' "${OPTIONAL_PLUGIN_PATHS[@]}")
185
+ $(yaml_list_items ${OPTIONAL_PLUGIN_PATHS[@]+"${OPTIONAL_PLUGIN_PATHS[@]}"})
151
186
 
152
187
  # Legacy alias — all plugin paths combined (kept for backward compatibility).
153
188
  plugin_paths:
154
- $(printf ' - %s\n' "${REQUIRED_PLUGIN_PATHS[@]}" "${OPTIONAL_PLUGIN_PATHS[@]}")
189
+ $(yaml_list_items ${REQUIRED_PLUGIN_PATHS[@]+"${REQUIRED_PLUGIN_PATHS[@]}"} ${OPTIONAL_PLUGIN_PATHS[@]+"${OPTIONAL_PLUGIN_PATHS[@]}"})
155
190
 
156
191
  # Jekyll config keys this theme expects.
157
192
  config_schema:
@@ -173,10 +208,25 @@ YAML
173
208
  )
174
209
 
175
210
  # ---------------------------------------------------------------------------
176
- if [[ "$DRY_RUN" == "true" ]]; then
177
- info "[DRY RUN] Would write _data/theme-manifest.yml:"
211
+ MANIFEST_FILE="$REPO_ROOT/_data/theme-manifest.yml"
212
+
213
+ if [[ "$CHECK" == "true" ]]; then
214
+ # Staleness = any difference other than the generation timestamp, which moves
215
+ # on every run and would otherwise report a false positive every time.
216
+ if [[ ! -f "$MANIFEST_FILE" ]]; then
217
+ error "Manifest missing: _data/theme-manifest.yml — run ./scripts/bin/manifest"
218
+ fi
219
+ if diff -u \
220
+ <(grep -v '^generated_at:' "$MANIFEST_FILE") \
221
+ <(echo "$MANIFEST_CONTENT" | grep -v '^generated_at:') >&2; then
222
+ success "Manifest is current (version $CURRENT_VERSION)" >&2
223
+ exit 0
224
+ fi
225
+ error "Manifest is stale — run ./scripts/bin/manifest and commit the result"
226
+ elif [[ "$DRY_RUN" == "true" ]]; then
227
+ info "[DRY RUN] Would write _data/theme-manifest.yml:" >&2
178
228
  echo "$MANIFEST_CONTENT"
179
229
  else
180
- echo "$MANIFEST_CONTENT" > "$REPO_ROOT/_data/theme-manifest.yml"
230
+ echo "$MANIFEST_CONTENT" > "$MANIFEST_FILE"
181
231
  success "Written: _data/theme-manifest.yml (version $CURRENT_VERSION)"
182
232
  fi
data/scripts/bin/validate CHANGED
@@ -391,7 +391,11 @@ if File.file?('_config_secrets_local.yml')
391
391
  assert(!tracked, '_config_secrets_local.yml must remain untracked and ignored')
392
392
  end
393
393
 
394
- ignored_dirs = %w[.git _site node_modules vendor .jekyll-cache .sass-cache test/fixtures]
394
+ # examples/ holds self-contained demo sites, each with its own _config.yml and
395
+ # _config_dev.yml. Those belong to the example, not to this site's config
396
+ # surface, so prune the tree rather than registering every example's configs in
397
+ # classified_files below (which would need editing for each new example).
398
+ ignored_dirs = %w[.git _site node_modules vendor .jekyll-cache .sass-cache test/fixtures examples]
395
399
  config_like_files = []
396
400
 
397
401
  Find.find('.') do |path|
@@ -0,0 +1,164 @@
1
+ #!/usr/bin/env python3
2
+ """Decide whether the Claude content review ACTUALLY ran, and fail loudly if not.
3
+
4
+ WHY THIS EXISTS (issue #418)
5
+ ----------------------------
6
+ `.github/workflows/ai-content-review.yml`'s agent step used to capture the
7
+ Claude CLI's exit status, echo it, and then never act on it. Whatever landed on
8
+ stdout was posted verbatim under the `ai-content-review-agent` sticky marker and
9
+ the step exited 0.
10
+
11
+ So when the OAuth credential was revoked (2026-08-18 → 08-24) the CLI printed
12
+
13
+ Failed to authenticate. API Error: 401 OAuth access token has been revoked.
14
+
15
+ to STDOUT, that single line became the "review", and every job reported success.
16
+ Run 32618860829 concluded `success` while posting exactly that to PR #414; run
17
+ 32656168775 shows all three agent steps green under the same conditions. Nothing
18
+ in the checks list, the run conclusion, or the annotations distinguished a
19
+ working editorial review from a dead one — for days, across several PRs.
20
+
21
+ Note what that rules out: a non-zero exit code is NOT sufficient to detect this.
22
+ The CLI reported the auth failure on stdout, and the step's own control flow
23
+ swallowed the status regardless. So all three ways the tier can be dead are
24
+ classified here:
25
+
26
+ 1. the CLI exited non-zero;
27
+ 2. it produced no output at all;
28
+ 3. it produced only a failure notice (the observed 401 case).
29
+
30
+ On any of those this writes an explicit failure notice for the sticky comment,
31
+ emits a ``::error::`` annotation so the condition is visible in the checks UI,
32
+ and exits 1 so the job goes red. The workflow's `Post agent review` step is
33
+ `if: always()`, so the comment is still posted — the reader gets a notice that
34
+ says the review did not run, instead of a raw API error dressed as a review.
35
+
36
+ Usage:
37
+ python3 scripts/ci/agent_review_result.py \\
38
+ --status <claude-exit-code> \\
39
+ --stdout <file> [--stderr <file>] \\
40
+ --out <comment-body-file>
41
+
42
+ Exit: 0 the review is real · 1 the tier failed · 2 bad invocation
43
+
44
+ Tests: scripts/ci/test_agent_review_result.py (run in CI via
45
+ scripts/test/lib/test_agent_review_result.sh → ./scripts/bin/test).
46
+ """
47
+
48
+ from __future__ import annotations
49
+
50
+ import argparse
51
+ import re
52
+ import sys
53
+ from pathlib import Path
54
+
55
+ TITLE = "## 🤖 Claude Code Content Review"
56
+
57
+ # Failure signatures the Claude CLI prints INSTEAD of a review. Kept narrow on
58
+ # purpose: every entry is a string the CLI emits on its own.
59
+ FAILURE_SIGNATURES = re.compile(
60
+ r"Failed to authenticate"
61
+ r"|OAuth access token has been revoked"
62
+ r"|API Error: (?:401|403|429|5\d\d)"
63
+ r"|authentication_error"
64
+ r"|invalid_api_key"
65
+ r"|Invalid API key"
66
+ r"|Credit balance is too low"
67
+ r"|Please run [`'\"]?claude login",
68
+ re.IGNORECASE,
69
+ )
70
+
71
+ # A genuine review is a structured Markdown document — a verdict, per-file
72
+ # findings, a summary. A failure notice is a line or two, and the failure is the
73
+ # FIRST thing on it. Requiring all three (short body, signature, signature near
74
+ # the top) stops a review that legitimately *discusses* an auth error from being
75
+ # thrown away as a failure — reviewing this repo's own credential-troubleshooting
76
+ # page would otherwise fail its own PR. Both directions are covered by the tests,
77
+ # and the bound is deliberately tight: an ambiguous LONG output containing an
78
+ # error is better shown to a human than silently discarded. A genuinely broken
79
+ # run that is also verbose almost always exits non-zero, which rule 1 catches.
80
+ MAX_FAILURE_NOTICE_LINES = 5
81
+ FAILURE_SIGNATURE_HEAD_LINES = 3
82
+
83
+
84
+ def nonblank_lines(text: str) -> list[str]:
85
+ return [ln for ln in text.splitlines() if ln.strip()]
86
+
87
+
88
+ def classify(status: int, stdout: str) -> str | None:
89
+ """Return a human-readable failure reason, or None if the review is real."""
90
+ lines = nonblank_lines(stdout)
91
+ if status != 0:
92
+ return f"the Claude CLI exited with status `{status}`"
93
+ if not lines:
94
+ return "the agent produced no output"
95
+ head = "\n".join(lines[:FAILURE_SIGNATURE_HEAD_LINES])
96
+ if len(lines) <= MAX_FAILURE_NOTICE_LINES and FAILURE_SIGNATURES.search(head):
97
+ return "the agent reported a credential / API failure instead of a review"
98
+ return None
99
+
100
+
101
+ def _details(summary: str, body: str) -> list[str]:
102
+ tail = "\n".join(body.splitlines()[-20:])
103
+ return [f"<details><summary>{summary}</summary>", "", "```", tail, "```", "", "</details>", ""]
104
+
105
+
106
+ def render_failure(reason: str, status: int, stdout: str, stderr: str) -> str:
107
+ out = [
108
+ TITLE,
109
+ "",
110
+ "> [!CAUTION]",
111
+ f"> **The editorial review did not run** — {reason}.",
112
+ "> This comment is a failure notice, not a review. The job has been",
113
+ "> failed on purpose so the condition is visible on the checks list",
114
+ "> rather than only in this comment.",
115
+ "",
116
+ f"Claude CLI exit status: `{status}`",
117
+ "",
118
+ ]
119
+ if stdout.strip():
120
+ out += _details("Agent stdout", stdout)
121
+ if stderr.strip():
122
+ out += _details("Agent stderr (tail)", stderr)
123
+ out += [
124
+ "If this is a credential problem, rotate `CLAUDE_CODE_OAUTH_TOKEN` for this",
125
+ "repository — see `docs/TOKEN-ROTATION.md` in bamr87/bamr87. The deterministic",
126
+ "tier (`scripts/content-review.rb`) is unaffected and posts separately.",
127
+ "",
128
+ ]
129
+ return "\n".join(out)
130
+
131
+
132
+ def main(argv: list[str] | None = None) -> int:
133
+ p = argparse.ArgumentParser(description=__doc__)
134
+ p.add_argument("--status", type=int, required=True, help="claude CLI exit code")
135
+ p.add_argument("--stdout", required=True, help="file holding the agent's stdout")
136
+ p.add_argument("--stderr", default=None, help="file holding the agent's stderr")
137
+ p.add_argument("--out", required=True, help="file to write the sticky comment body to")
138
+ args = p.parse_args(argv)
139
+
140
+ stdout = Path(args.stdout).read_text(encoding="utf-8", errors="replace") \
141
+ if Path(args.stdout).exists() else ""
142
+ stderr = ""
143
+ if args.stderr and Path(args.stderr).exists():
144
+ stderr = Path(args.stderr).read_text(encoding="utf-8", errors="replace")
145
+
146
+ reason = classify(args.status, stdout)
147
+ out_path = Path(args.out)
148
+
149
+ if reason is None:
150
+ out_path.write_text(f"{TITLE}\n\n{stdout}", encoding="utf-8")
151
+ print(f"agent-review-result: review looks real "
152
+ f"({len(nonblank_lines(stdout))} non-blank lines).")
153
+ return 0
154
+
155
+ out_path.write_text(render_failure(reason, args.status, stdout, stderr), encoding="utf-8")
156
+ print(f"::error title=Claude content review did not run::{reason} "
157
+ f"(claude exit {args.status}). The sticky comment carries a failure notice, "
158
+ f"not a review.")
159
+ print(f"agent-review-result: FAILED — {reason}", file=sys.stderr)
160
+ return 1
161
+
162
+
163
+ if __name__ == "__main__":
164
+ raise SystemExit(main())