jekyll-theme-zer0 1.27.0 → 1.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +812 -13
- data/_data/backlog.yml +356 -2
- data/_data/consumers.yml +252 -0
- data/_data/features.yml +234 -5
- data/_data/i18n/fr.yml +103 -0
- data/_data/i18n/manifest.yml +1529 -0
- data/_data/ingredient_densities.yml +122 -0
- data/_data/navigation/docs.yml +2 -0
- data/_data/navigation/main.yml +22 -0
- data/_data/recipe_courses.yml +64 -0
- data/_data/series.yml +19 -0
- data/_data/theme-manifest.yml +387 -277
- data/_data/ui-text.yml +8 -0
- data/_includes/README.md +36 -3
- data/_includes/analytics/google-tag-manager-body.html +11 -2
- data/_includes/analytics/google-tag-manager-head.html +16 -6
- data/_includes/analytics/posthog.html +33 -4
- data/_includes/components/abc-letter.html +43 -0
- data/_includes/components/background-customizer.html +2 -2
- data/_includes/components/book-card.html +42 -0
- data/_includes/components/book-nav.html +80 -0
- data/_includes/components/book-plate.html +31 -0
- data/_includes/components/book-toc.html +47 -0
- data/_includes/components/bookshelf.html +68 -0
- data/_includes/components/card-grid.html +60 -0
- data/_includes/components/cookie-consent.html +4 -4
- data/_includes/components/data-card.html +95 -0
- data/_includes/components/halfmoon.html +5 -1
- data/_includes/components/info-section.html +8 -3
- data/_includes/components/language-toggle.html +168 -21
- data/_includes/components/page-feedback.html +65 -2
- data/_includes/components/page-views-init.html +55 -0
- data/_includes/components/page-views.html +33 -0
- data/_includes/components/recipe-card.html +67 -0
- data/_includes/components/recipe-duration.html +50 -0
- data/_includes/components/recipe-grams.html +58 -0
- data/_includes/components/recipe-index.html +96 -0
- data/_includes/components/recipe-ingredients.html +90 -0
- data/_includes/components/recipe-meta.html +96 -0
- data/_includes/components/recipe-nutrition.html +57 -0
- data/_includes/components/recipe-qty.html +73 -0
- data/_includes/components/recipe-ratio.html +151 -0
- data/_includes/components/recipe-scaler.html +73 -0
- data/_includes/components/recipe-steps.html +86 -0
- data/_includes/components/recipe-temp.html +45 -0
- data/_includes/components/search-modal.html +2 -2
- data/_includes/components/shortcuts-modal.html +3 -0
- data/_includes/components/theme-controls-bar.html +10 -2
- data/_includes/components/theme-customizer.html +8 -2
- data/_includes/components/theme-info.html +8 -1
- data/_includes/content/jsonld-software.html +22 -3
- data/_includes/content/seo.html +9 -3
- data/_includes/core/color-mode-init.html +13 -4
- data/_includes/core/favicon.html +46 -0
- data/_includes/core/footer.html +18 -16
- data/_includes/core/head.html +21 -0
- data/_includes/core/header.html +7 -4
- data/_includes/custom/body-end.html +18 -0
- data/_includes/custom/body-start.html +17 -0
- data/_includes/custom/footer.html +18 -0
- data/_includes/custom/head.html +18 -0
- data/_includes/navigation/local-graph.html +28 -2
- data/_includes/navigation/nav-tree.html +3 -3
- data/_includes/navigation/section-sidebar.html +93 -11
- data/_includes/navigation/sidebar-config.html +21 -0
- data/_includes/navigation/sidebar-left.html +2 -1
- data/_includes/navigation/sidebar-nav.html +4 -0
- data/_includes/navigation/sidebar-pagetree.html +150 -0
- data/_includes/navigation/sidebar-right.html +2 -1
- data/_includes/navigation/unified-drawer.html +8 -2
- data/_includes/obsidian/full-graph.html +165 -136
- data/_includes/setup/wizard.html +173 -86
- data/_includes/stats/stats-metrics.html +2 -2
- data/_layouts/404.html +260 -0
- data/_layouts/README.md +4 -0
- data/_layouts/admin.html +2 -2
- data/_layouts/article.html +5 -0
- data/_layouts/book-abc.html +106 -0
- data/_layouts/book-story.html +91 -0
- data/_layouts/book.html +113 -0
- data/_layouts/collection.html +17 -5
- data/_layouts/cookbook.html +88 -0
- data/_layouts/default.html +4 -4
- data/_layouts/home.html +5 -2
- data/_layouts/landing.html +9 -0
- data/_layouts/news.html +155 -31
- data/_layouts/recipe.html +274 -0
- data/_layouts/root.html +38 -5
- data/_layouts/section.html +96 -25
- data/_sass/components/_book.scss +423 -0
- data/_sass/components/_callout.scss +1 -1
- data/_sass/components/_footer.scss +37 -1
- data/_sass/components/_page-views.scss +36 -0
- data/_sass/components/_recipe.scss +506 -0
- data/_sass/components/_setup-wizard.scss +235 -0
- data/_sass/components/_ui-enhancements.scss +6 -6
- data/_sass/core/_navbar.scss +263 -15
- data/_sass/core/_obsidian.scss +286 -6
- data/_sass/layouts/_landing.scss +2 -2
- data/_sass/theme/_backgrounds.scss +21 -8
- data/_sass/tokens/_color.scss +6 -0
- data/_sass/tokens/_index.scss +2 -0
- data/_sass/tokens/_radius.scss +21 -0
- data/_sass/tokens/_typography.scss +4 -0
- data/_sass/utilities/_focus.scss +14 -0
- data/assets/css/main.scss +4 -0
- data/assets/js/auto-hide-nav.js +5 -1
- data/assets/js/halfmoon.js +26 -0
- data/assets/js/modules/navigation/navbar.js +55 -0
- data/assets/js/obsidian-graph.js +702 -264
- data/assets/js/obsidian-local-graph.js +161 -54
- data/assets/js/page-views.js +372 -0
- data/assets/js/recipe-scaler.js +501 -0
- data/assets/js/search-modal.js +14 -1
- data/assets/js/setup-wizard.js +322 -20
- data/scripts/README.md +29 -0
- data/scripts/bin/audit-consumer +39 -7
- data/scripts/bin/giscus-discussions +213 -14
- data/scripts/bin/manifest +66 -16
- data/scripts/bin/validate +5 -1
- data/scripts/ci/agent_review_result.py +164 -0
- data/scripts/ci/test_agent_review_result.py +172 -0
- data/scripts/design-system-check.rb +170 -0
- data/scripts/install/README.md +47 -6
- data/scripts/install/ai/client.sh +302 -93
- data/scripts/install/ai/prompts/spec.schema.json +1 -1
- data/scripts/install/ai/wizard.sh +10 -5
- data/scripts/install/apply.sh +7 -3
- data/scripts/install/cli.sh +54 -4
- data/scripts/install/config.sh +167 -0
- data/scripts/install/doctor.sh +38 -0
- data/scripts/install/plan.sh +10 -0
- data/scripts/install/spec.sh +15 -7
- data/scripts/install/template.sh +4 -0
- data/scripts/lib/audit.sh +42 -2
- data/scripts/lint-liquid-raw.rb +137 -0
- data/scripts/propagate.rb +277 -0
- data/scripts/test/lib/run_tests.sh +2 -1
- data/scripts/test/lib/test_agent_review_result.sh +27 -0
- data/scripts/translate.rb +161 -15
- metadata +56 -2
|
@@ -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
|
|
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="
|
|
89
|
+
p="${p#*/}" # strip host, keep the path
|
|
81
90
|
fi
|
|
82
|
-
|
|
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
|
|
99
|
-
#
|
|
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
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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:$
|
|
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 == \"$
|
|
115
|
-
|
|
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
|
-
[[
|
|
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" ]]
|
|
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
|
|
68
|
-
#
|
|
69
|
-
|
|
70
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
145
|
-
#
|
|
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
|
-
$(
|
|
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
|
-
$(
|
|
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
|
-
$(
|
|
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
|
-
|
|
177
|
-
|
|
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" > "$
|
|
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
|
-
|
|
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())
|