jekyll-theme-zer0 1.27.0 → 1.28.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 (78) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +386 -4
  3. data/_data/consumers.yml +147 -0
  4. data/_data/features.yml +77 -5
  5. data/_data/i18n/fr.yml +98 -0
  6. data/_data/i18n/manifest.yml +1502 -0
  7. data/_data/navigation/docs.yml +2 -0
  8. data/_data/navigation/main.yml +6 -0
  9. data/_data/series.yml +19 -0
  10. data/_data/theme-manifest.yml +348 -266
  11. data/_includes/README.md +21 -2
  12. data/_includes/analytics/google-tag-manager-body.html +11 -2
  13. data/_includes/analytics/google-tag-manager-head.html +16 -6
  14. data/_includes/analytics/posthog.html +33 -4
  15. data/_includes/components/abc-letter.html +43 -0
  16. data/_includes/components/book-card.html +42 -0
  17. data/_includes/components/book-nav.html +80 -0
  18. data/_includes/components/book-plate.html +31 -0
  19. data/_includes/components/book-toc.html +47 -0
  20. data/_includes/components/bookshelf.html +68 -0
  21. data/_includes/components/card-grid.html +60 -0
  22. data/_includes/components/data-card.html +95 -0
  23. data/_includes/components/halfmoon.html +5 -1
  24. data/_includes/components/page-feedback.html +65 -2
  25. data/_includes/components/theme-controls-bar.html +10 -2
  26. data/_includes/components/theme-customizer.html +8 -2
  27. data/_includes/content/seo.html +9 -3
  28. data/_includes/core/color-mode-init.html +13 -4
  29. data/_includes/core/favicon.html +46 -0
  30. data/_includes/core/head.html +9 -0
  31. data/_includes/custom/body-end.html +18 -0
  32. data/_includes/custom/body-start.html +17 -0
  33. data/_includes/custom/footer.html +18 -0
  34. data/_includes/custom/head.html +18 -0
  35. data/_includes/navigation/local-graph.html +28 -2
  36. data/_includes/navigation/nav-tree.html +3 -3
  37. data/_includes/navigation/sidebar-config.html +21 -0
  38. data/_includes/navigation/sidebar-nav.html +4 -0
  39. data/_includes/navigation/sidebar-pagetree.html +150 -0
  40. data/_includes/navigation/unified-drawer.html +8 -2
  41. data/_includes/obsidian/full-graph.html +165 -136
  42. data/_layouts/404.html +260 -0
  43. data/_layouts/README.md +2 -0
  44. data/_layouts/book-abc.html +106 -0
  45. data/_layouts/book-story.html +91 -0
  46. data/_layouts/book.html +113 -0
  47. data/_layouts/collection.html +17 -5
  48. data/_layouts/home.html +5 -2
  49. data/_layouts/landing.html +9 -0
  50. data/_layouts/news.html +155 -31
  51. data/_layouts/root.html +16 -2
  52. data/_layouts/section.html +57 -15
  53. data/_sass/components/_book.scss +423 -0
  54. data/_sass/core/_navbar.scss +13 -0
  55. data/_sass/core/_obsidian.scss +286 -6
  56. data/_sass/theme/_backgrounds.scss +21 -8
  57. data/assets/css/main.scss +1 -0
  58. data/assets/js/auto-hide-nav.js +5 -1
  59. data/assets/js/halfmoon.js +26 -0
  60. data/assets/js/obsidian-graph.js +702 -264
  61. data/assets/js/obsidian-local-graph.js +161 -54
  62. data/assets/js/search-modal.js +4 -1
  63. data/scripts/bin/manifest +31 -4
  64. data/scripts/bin/validate +5 -1
  65. data/scripts/install/README.md +47 -6
  66. data/scripts/install/ai/client.sh +302 -93
  67. data/scripts/install/ai/prompts/spec.schema.json +1 -1
  68. data/scripts/install/ai/wizard.sh +10 -5
  69. data/scripts/install/apply.sh +7 -3
  70. data/scripts/install/cli.sh +54 -4
  71. data/scripts/install/config.sh +167 -0
  72. data/scripts/install/doctor.sh +38 -0
  73. data/scripts/install/plan.sh +10 -0
  74. data/scripts/install/spec.sh +15 -7
  75. data/scripts/install/template.sh +4 -0
  76. data/scripts/propagate.rb +277 -0
  77. data/scripts/translate.rb +90 -0
  78. metadata +27 -2
@@ -0,0 +1,167 @@
1
+ #!/bin/bash
2
+ # =============================================================================
3
+ # scripts/install/config.sh — User config-file layer
4
+ # =============================================================================
5
+ # Lets a site persist its installer choices in a small YAML file instead of
6
+ # re-typing flags every run. Discovered files are merged low→high precedence:
7
+ #
8
+ # 1. ~/.config/zer0/install.yml (user-global defaults)
9
+ # 2. <target>/zer0.install.yml (project-local)
10
+ # 3. <target>/.zer0/config.yml (project-local, hidden)
11
+ # 4. $ZER0_CONFIG / --config FILE (explicit — wins over the rest)
12
+ #
13
+ # In the plan pipeline this sits between profile defaults and env/flag
14
+ # overrides: defaults < profile < CONFIG FILE < env vars < CLI flags.
15
+ #
16
+ # Recognised keys (nested or flat/dotted both work):
17
+ # profile
18
+ # site.title|description|author|email|url|timezone|locale
19
+ # github.user|repo|pages_branch|enable_pages
20
+ # theme.source|version
21
+ # deploy (scalar or list → space-joined)
22
+ # agents (scalar or list → space-joined)
23
+ # tasks (scalar or list → space-joined)
24
+ # ai.provider (auto|claude-cli|anthropic|openai|none)
25
+ # ai.model
26
+ #
27
+ # API keys are deliberately NOT read from config files — they stay in the
28
+ # environment only (see ai/client.sh).
29
+ #
30
+ # Bash 3.2 compatible. No `declare -A`, no `set -euo pipefail` here.
31
+ # =============================================================================
32
+ [[ -n "${_HAS_CONFIG_LIB:-}" ]] && return 0
33
+ _HAS_CONFIG_LIB=1
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # config_discover TARGET_DIR → print existing config files, low→high priority.
37
+ # ---------------------------------------------------------------------------
38
+ config_discover() {
39
+ local target="${1:-$(pwd)}"
40
+ local f
41
+ for f in \
42
+ "${HOME:-/nonexistent}/.config/zer0/install.yml" \
43
+ "${target}/zer0.install.yml" \
44
+ "${target}/zer0.install.yaml" \
45
+ "${target}/.zer0/config.yml" \
46
+ "${_FLAG_CONFIG:-}" \
47
+ "${ZER0_CONFIG:-}" ; do
48
+ [[ -n "$f" && -f "$f" ]] && printf '%s\n' "$f"
49
+ done
50
+ }
51
+
52
+ # ---------------------------------------------------------------------------
53
+ # _config_flatten FILE → emit `key=value` for a small YAML subset:
54
+ # - top-level scalars foo: bar → foo=bar
55
+ # - one level of nested maps site:\n title: x → site.title=x
56
+ # - block lists deploy:\n - a → deploy=a b
57
+ # - inline flow lists deploy: [a, b] → deploy=a b
58
+ # Quotes and inline comments are stripped. Not a full YAML parser — just
59
+ # enough for installer config, mirroring plan.sh's awk profile reader.
60
+ # ---------------------------------------------------------------------------
61
+ _config_flatten() {
62
+ local file="$1"
63
+ [[ -f "$file" ]] || return 0
64
+ awk '
65
+ function strip(s) {
66
+ sub(/[[:space:]]*#.*$/, "", s) # trailing comment
67
+ sub(/^[[:space:]]+/, "", s); sub(/[[:space:]]+$/, "", s)
68
+ gsub(/^["'"'"']|["'"'"']$/, "", s) # surrounding quotes
69
+ return s
70
+ }
71
+ # blank / comment lines
72
+ /^[[:space:]]*$/ { next }
73
+ /^[[:space:]]*#/ { next }
74
+ {
75
+ indent = 0
76
+ tmp = $0
77
+ while (substr(tmp,1,1) == " ") { indent++; tmp = substr(tmp,2) }
78
+ }
79
+ # list item under the current parent key
80
+ indent > 0 && tmp ~ /^-[[:space:]]+/ {
81
+ if (listkey != "") {
82
+ item = tmp; sub(/^-[[:space:]]+/, "", item); item = strip(item)
83
+ vals[listkey] = (vals[listkey] == "" ? item : vals[listkey] " " item)
84
+ }
85
+ next
86
+ }
87
+ # key: value (any depth we care about is 0 or 1)
88
+ match(tmp, /^[A-Za-z0-9_.-]+[[:space:]]*:/) {
89
+ key = tmp; sub(/[[:space:]]*:.*$/, "", key)
90
+ val = tmp; sub(/^[A-Za-z0-9_.-]+[[:space:]]*:/, "", val); val = strip(val)
91
+
92
+ if (indent == 0) {
93
+ parent = key
94
+ listkey = ""
95
+ if (val == "") { listkey = key; next } # map/list header
96
+ # inline flow list? key: [a, b, c]
97
+ if (val ~ /^\[.*\]$/) {
98
+ gsub(/^\[|\]$/, "", val); gsub(/[[:space:],]+/, " ", val)
99
+ val = strip(val)
100
+ }
101
+ print key "=" val
102
+ } else {
103
+ # nested child of the last top-level parent
104
+ if (parent != "") print parent "." key "=" val
105
+ }
106
+ next
107
+ }
108
+ END {
109
+ for (k in vals) print k "=" vals[k]
110
+ }
111
+ ' "$file"
112
+ }
113
+
114
+ # ---------------------------------------------------------------------------
115
+ # config_apply_file FILE → map recognised keys onto SPEC_* globals + AI env.
116
+ # ---------------------------------------------------------------------------
117
+ config_apply_file() {
118
+ local file="$1"
119
+ [[ -f "$file" ]] || return 0
120
+ log_debug "config: loading ${file}"
121
+
122
+ local line key val
123
+ while IFS= read -r line; do
124
+ key="${line%%=*}"
125
+ val="${line#*=}"
126
+ [[ -z "$key" || "$key" == "$line" ]] && continue
127
+ case "$key" in
128
+ profile) SPEC_PROFILE="$val" ;;
129
+ site.title|site_title) SPEC_SITE_TITLE="$val" ;;
130
+ site.description|site_description) SPEC_SITE_DESCRIPTION="$val" ;;
131
+ site.author|site_author) SPEC_SITE_AUTHOR="$val" ;;
132
+ site.email|site_email) SPEC_SITE_EMAIL="$val" ;;
133
+ site.url|site_url) SPEC_SITE_URL="$val" ;;
134
+ site.timezone|site_timezone) SPEC_SITE_TIMEZONE="$val" ;;
135
+ site.locale|site_locale) SPEC_SITE_LOCALE="$val" ;;
136
+ github.user|github_user) SPEC_GITHUB_USER="$val" ;;
137
+ github.repo|github_repo) SPEC_GITHUB_REPO="$val" ;;
138
+ github.pages_branch) SPEC_GITHUB_PAGES_BRANCH="$val" ;;
139
+ github.enable_pages) SPEC_GITHUB_ENABLE_PAGES="$val" ;;
140
+ theme.source|theme_source) SPEC_THEME_SOURCE="$val" ;;
141
+ theme.version|theme_version) SPEC_THEME_VERSION="$val" ;;
142
+ deploy|deploy_targets) SPEC_DEPLOY="$val" ;;
143
+ agents|agent_files) SPEC_AGENTS="$val" ;;
144
+ tasks) SPEC_TASKS="$val" ;;
145
+ ai.provider|ai_provider)
146
+ SPEC_AI_PROVIDER="$val"
147
+ export ZER0_AI_PROVIDER="${ZER0_AI_PROVIDER:-$val}" ;;
148
+ ai.model|ai_model)
149
+ SPEC_AI_MODEL="$val"
150
+ export ZER0_AI_MODEL="${ZER0_AI_MODEL:-$val}" ;;
151
+ *) log_debug "config: ignoring unknown key '${key}'" ;;
152
+ esac
153
+ done < <(_config_flatten "$file")
154
+ return 0
155
+ }
156
+
157
+ # ---------------------------------------------------------------------------
158
+ # config_load TARGET_DIR → apply every discovered config file in order.
159
+ # ---------------------------------------------------------------------------
160
+ config_load() {
161
+ local target="${1:-$(pwd)}"
162
+ local f
163
+ while IFS= read -r f; do
164
+ [[ -n "$f" ]] && config_apply_file "$f"
165
+ done < <(config_discover "$target")
166
+ return 0
167
+ }
@@ -118,6 +118,43 @@ doctor_check_gh() {
118
118
  _doctor_pass "GitHub CLI $ghver"
119
119
  }
120
120
 
121
+ # ---------------------------------------------------------------------------
122
+ # doctor_check_ai — report which AI provider (if any) will serve wizard/
123
+ # suggest/diagnose. Warn-only: AI is always opt-in and never blocks install.
124
+ # ---------------------------------------------------------------------------
125
+ doctor_check_ai() {
126
+ if [[ "$(type -t ai_client_provider)" != "function" ]]; then
127
+ local _d
128
+ _d="${_CLI_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" 2>/dev/null && pwd)}"
129
+ [[ -f "${_d}/ai/client.sh" ]] && source "${_d}/ai/client.sh"
130
+ fi
131
+ if [[ "$(type -t ai_client_provider)" != "function" ]]; then
132
+ _doctor_warn "AI: client module unavailable (wizard/suggest/diagnose disabled)"
133
+ return 0
134
+ fi
135
+
136
+ if [[ "${ZER0_NO_AI:-0}" == "1" ]]; then
137
+ _doctor_pass "AI: disabled via ZER0_NO_AI (rule-based fallbacks only)"
138
+ return 0
139
+ fi
140
+ case "${ZER0_AI_PROVIDER:-}" in
141
+ none|off|disabled)
142
+ _doctor_pass "AI: disabled by choice (rule-based fallbacks only)"
143
+ return 0 ;;
144
+ esac
145
+
146
+ local provider
147
+ provider="$(ai_client_provider)"
148
+ if [[ "$provider" == "none" ]] || ! ai_client_available; then
149
+ _doctor_warn "AI: no provider available — install the 'claude' CLI (Claude Code OAuth), or set CLAUDE_CODE_OAUTH_TOKEN / ANTHROPIC_API_KEY / OPENAI_API_KEY"
150
+ return 0
151
+ fi
152
+
153
+ local model
154
+ model="$(ai_client_model "$provider")"
155
+ _doctor_pass "AI: ${provider} via $(ai_client_auth_source)${model:+ (model ${model})}"
156
+ }
157
+
121
158
  # ---------------------------------------------------------------------------
122
159
  # doctor_check_writable TARGET_DIR
123
160
  # ---------------------------------------------------------------------------
@@ -155,6 +192,7 @@ doctor_run() {
155
192
  doctor_check_docker
156
193
  doctor_check_git || failures=$(( failures + 1 ))
157
194
  doctor_check_gh
195
+ doctor_check_ai
158
196
  [[ -n "$target" ]] && { doctor_check_writable "$target" || failures=$(( failures + 1 )); }
159
197
 
160
198
  if [[ $failures -eq 0 ]]; then
@@ -144,6 +144,8 @@ plan_apply_flags() {
144
144
  [[ -n "${_FLAG_DEPLOY:-}" ]] && SPEC_DEPLOY="$_FLAG_DEPLOY"
145
145
  [[ -n "${_FLAG_AGENTS:-}" ]] && SPEC_AGENTS="$_FLAG_AGENTS"
146
146
  [[ -n "${_FLAG_TASKS:-}" ]] && SPEC_TASKS="$_FLAG_TASKS"
147
+ [[ -n "${_FLAG_AI_PROVIDER:-}" ]] && SPEC_AI_PROVIDER="$_FLAG_AI_PROVIDER"
148
+ [[ -n "${_FLAG_AI_MODEL:-}" ]] && SPEC_AI_MODEL="$_FLAG_AI_MODEL"
147
149
 
148
150
  # Booleans — flags are set to "1" when present
149
151
  [[ "${_FLAG_DRY_RUN:-0}" == "1" ]] && SPEC_OPT_DRY_RUN=true
@@ -231,12 +233,19 @@ plan_build() {
231
233
  # Layer 1: Profile defaults
232
234
  [[ -n "$profile_file" ]] && plan_load_profile "$profile_file"
233
235
 
236
+ # Layer 1.5: User config file(s) — override profile, sit below env/flags.
237
+ if [[ "$(type -t config_load)" == "function" ]]; then
238
+ config_load "$target_dir"
239
+ fi
240
+
234
241
  # Layer 2: Environment variable overrides (ZER0_SITE_* etc.)
235
242
  [[ -n "${ZER0_SITE_TITLE:-}" ]] && SPEC_SITE_TITLE="$ZER0_SITE_TITLE"
236
243
  [[ -n "${ZER0_SITE_AUTHOR:-}" ]] && SPEC_SITE_AUTHOR="$ZER0_SITE_AUTHOR"
237
244
  [[ -n "${ZER0_SITE_EMAIL:-}" ]] && SPEC_SITE_EMAIL="$ZER0_SITE_EMAIL"
238
245
  [[ -n "${ZER0_GITHUB_USER:-}" ]] && SPEC_GITHUB_USER="$ZER0_GITHUB_USER"
239
246
  [[ -n "${ZER0_GITHUB_REPO:-}" ]] && SPEC_GITHUB_REPO="$ZER0_GITHUB_REPO"
247
+ [[ -n "${ZER0_AI_PROVIDER:-}" ]] && SPEC_AI_PROVIDER="$ZER0_AI_PROVIDER"
248
+ [[ -n "${ZER0_AI_MODEL:-}" ]] && SPEC_AI_MODEL="$ZER0_AI_MODEL"
240
249
 
241
250
  # Layer 3: CLI flags (highest priority)
242
251
  plan_apply_flags
@@ -271,6 +280,7 @@ plan_build() {
271
280
  SPEC_OPT_DRY_RUN SPEC_OPT_FORCE SPEC_OPT_BACKUP \
272
281
  SPEC_OPT_NON_INTERACTIVE SPEC_OPT_OUTPUT SPEC_OPT_AUTO_ACCEPT \
273
282
  SPEC_OPT_SKIP_DOCTOR SPEC_OPT_VERBOSE \
283
+ SPEC_AI_PROVIDER SPEC_AI_MODEL \
274
284
  SITE_TITLE SITE_DESCRIPTION SITE_AUTHOR SITE_EMAIL SITE_URL \
275
285
  SITE_TIMEZONE SITE_LOCALE GITHUB_USER GITHUB_REPO \
276
286
  GITHUB_PAGES_BRANCH THEME_SOURCE REPOSITORY_NAME INSTALL_PROFILE
@@ -85,7 +85,7 @@ spec_default() {
85
85
  },
86
86
  "ai": {
87
87
  "used": false,
88
- "provider": "openai",
88
+ "provider": "auto",
89
89
  "model": "",
90
90
  "tokens_estimated": 0,
91
91
  "spec_hash": ""
@@ -108,10 +108,18 @@ spec_write() {
108
108
  local out_file="$1"
109
109
  local dry_run="${_FS_DRY_RUN:-0}"
110
110
 
111
- # Build tasks array from space-separated SPEC_TASKS
111
+ # Build tasks array from space-separated SPEC_TASKS.
112
+ # Distinguish UNSET (→ default task list) from EMPTY (→ [], e.g. a
113
+ # deploy-only run). Using `${SPEC_TASKS:-default}` here would turn an
114
+ # intentional empty list back into the full default and clobber content.
112
115
  local tasks_json=""
113
- local t
114
- for t in ${SPEC_TASKS:-config gemfile docker pages nav data gitignore readme marker}; do
116
+ local t _tasks_src
117
+ if [[ -z "${SPEC_TASKS+set}" ]]; then
118
+ _tasks_src="config gemfile docker pages nav data gitignore readme marker"
119
+ else
120
+ _tasks_src="${SPEC_TASKS}"
121
+ fi
122
+ for t in ${_tasks_src}; do
115
123
  tasks_json="${tasks_json}\"${t}\","
116
124
  done
117
125
  tasks_json="[${tasks_json%,}]"
@@ -168,7 +176,7 @@ spec_write() {
168
176
  },
169
177
  "ai": {
170
178
  "used": ${SPEC_AI_USED:-false},
171
- "provider": "${SPEC_AI_PROVIDER:-openai}",
179
+ "provider": "${SPEC_AI_PROVIDER:-auto}",
172
180
  "model": "${SPEC_AI_MODEL:-}",
173
181
  "tokens_estimated": ${SPEC_AI_TOKENS:-0},
174
182
  "spec_hash": ""
@@ -248,7 +256,7 @@ _spec_read_jq() {
248
256
  SPEC_OPT_SKIP_DOCTOR=$(jq -r '.options.skip_doctor // false' "$f")
249
257
  SPEC_OPT_VERBOSE=$(jq -r '.options.verbose // false' "$f")
250
258
  SPEC_AI_USED=$(jq -r '.ai.used // false' "$f")
251
- SPEC_AI_PROVIDER=$(jq -r '.ai.provider // "openai"' "$f")
259
+ SPEC_AI_PROVIDER=$(jq -r '.ai.provider // "auto"' "$f")
252
260
  SPEC_AI_MODEL=$(jq -r '.ai.model // ""' "$f")
253
261
  SPEC_AI_TOKENS=$(jq -r '.ai.tokens_estimated // 0' "$f")
254
262
  SPEC_SCRAPE_SOURCE_URL=$(jq -r '.scrape.source_url // ""' "$f")
@@ -369,7 +377,7 @@ _spec_read_awk() {
369
377
  SPEC_OPT_SKIP_DOCTOR="${SPEC_OPT_SKIP_DOCTOR:-false}"
370
378
  SPEC_OPT_VERBOSE="${SPEC_OPT_VERBOSE:-false}"
371
379
  SPEC_AI_USED="${SPEC_AI_USED:-false}"
372
- SPEC_AI_PROVIDER="${SPEC_AI_PROVIDER:-openai}"
380
+ SPEC_AI_PROVIDER="${SPEC_AI_PROVIDER:-auto}"
373
381
  SPEC_AI_MODEL="${SPEC_AI_MODEL:-}"
374
382
  SPEC_AI_TOKENS="${SPEC_AI_TOKENS:-0}"
375
383
  SPEC_SCRAPE_SOURCE_URL="${SPEC_SCRAPE_SOURCE_URL:-$(awk '/"source_url"/ { gsub(/.*"source_url"[[:space:]]*:[[:space:]]*"/, ""); gsub(/".*/, ""); print; exit }' "$f" 2>/dev/null)}"
@@ -61,6 +61,7 @@ tmpl_render() {
61
61
  -e "s|{{THEME_DISPLAY_NAME}}|${THEME_DISPLAY_NAME:-Zer0-Mistakes}|g" \
62
62
  -e "s|{{THEME_VERSION}}|${THEME_VERSION:-}|g" \
63
63
  -e "s|{{THEME_SOURCE}}|${THEME_SOURCE:-gem}|g" \
64
+ -e "s|{{THEME_REMOTE}}|${THEME_REMOTE:-bamr87/zer0-mistakes}|g" \
64
65
  -e "s|{{GITHUB_USER}}|${GITHUB_USER:-}|g" \
65
66
  -e "s|{{FORK_GITHUB_USER}}|${GITHUB_USER:-}|g" \
66
67
  -e "s|{{GITHUB_REPO}}|${GITHUB_REPO:-}|g" \
@@ -72,6 +73,7 @@ tmpl_render() {
72
73
  -e "s|{{REMOTE_BRANCH}}|${REMOTE_BRANCH:-${GITHUB_PAGES_BRANCH:-gh-pages}}|g" \
73
74
  -e "s|{{REPOSITORY_NAME}}|${REPOSITORY_NAME:-${GITHUB_REPO:-my-site}}|g" \
74
75
  -e "s|{{SITE_TITLE}}|${SITE_TITLE:-My Jekyll Site}|g" \
76
+ -e "s|{{SITE_NAME}}|${SITE_TITLE:-My Jekyll Site}|g" \
75
77
  -e "s|{{SITE_DESCRIPTION}}|${SITE_DESCRIPTION:-A Jekyll site built with zer0-mistakes}|g" \
76
78
  -e "s|{{SITE_AUTHOR}}|${SITE_AUTHOR:-Site Author}|g" \
77
79
  -e "s|{{SITE_EMAIL}}|${SITE_EMAIL:-}|g" \
@@ -87,6 +89,8 @@ tmpl_render() {
87
89
  -e "s|{{COMMONMARKER_MACOS_VERSION}}|${COMMONMARKER_MACOS_VERSION:-~> 0.23}|g" \
88
90
  -e "s|{{GITHUB_PAGES_MAX_VERSION}}|${GITHUB_PAGES_MAX_VERSION:-232}|g" \
89
91
  -e "s|{{RUBY_MIN_VERSION_MACOS}}|${RUBY_MIN_VERSION_MACOS:-2.6.0}|g" \
92
+ -e "s|{{DEFAULT_BRANCH}}|${DEFAULT_BRANCH:-main}|g" \
93
+ -e "s|{{RUBY_VERSION}}|${RUBY_VERSION:-3.3}|g" \
90
94
  -e "s|{{INSTALL_PROFILE}}|${INSTALL_PROFILE:-default}|g" \
91
95
  -e "s|{{INSTALL_MODE}}|${INSTALL_MODE:-full}|g" \
92
96
  -e "s|{{CURRENT_DATE}}|$(date +%Y-%m-%d)|g" \
@@ -0,0 +1,277 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # =============================================================================
5
+ # scripts/propagate.rb — theme release fan-out
6
+ # =============================================================================
7
+ #
8
+ # Reads _data/consumers.yml (the downstream registry) and either REPORTS which
9
+ # consumers have drifted behind the current theme version, or DISPATCHES a
10
+ # `theme-release` event to each one so it opens its own bump PR.
11
+ #
12
+ # The problem this solves: a release used to end at RubyGems. Nothing told the
13
+ # five sites built on this theme that a new version existed, so their pins sat
14
+ # where someone last edited them by hand — all of them a full minor behind when
15
+ # 1.27.0 shipped.
16
+ #
17
+ # Dispatch, rather than this repo opening PRs directly, because every consumer
18
+ # gates differently: it-journey runs `make build-ci` + `make content-audit`,
19
+ # bashconsultants builds in Docker and runs its editorial lint, the org hubs
20
+ # re-roll member configs with provision-org-sites.rb. Each repo owns its own
21
+ # validation; this script only announces the release.
22
+ #
23
+ # Usage:
24
+ # ruby scripts/propagate.rb # report drift (default)
25
+ # ruby scripts/propagate.rb --format json # machine-readable report
26
+ # ruby scripts/propagate.rb --strict # exit 1 if any consumer is behind
27
+ # ruby scripts/propagate.rb --dispatch # fire theme-release events
28
+ # ruby scripts/propagate.rb --dispatch --dry-run
29
+ #
30
+ # Auth: uses `gh` when available, else GH_TOKEN / GITHUB_TOKEN / LIFECYCLE_PAT
31
+ # against the REST API. Reporting works unauthenticated for public repos;
32
+ # --dispatch always needs a token with `repo` scope on the target.
33
+ # =============================================================================
34
+
35
+ require 'yaml'
36
+ require 'json'
37
+ require 'net/http'
38
+ require 'uri'
39
+ require 'optparse'
40
+ require 'base64'
41
+
42
+ REPO_ROOT = File.expand_path('..', __dir__)
43
+ REGISTRY = File.join(REPO_ROOT, '_data', 'consumers.yml')
44
+ VERSION_RB = File.join(REPO_ROOT, 'lib', 'jekyll-theme-zer0', 'version.rb')
45
+
46
+ # Version-bearing patterns per pin kind. `theme_name` and `path_gem` carry no
47
+ # version by design — they are tracked so a bump does not miss the file that
48
+ # activates the theme, but they never report drift.
49
+ PIN_PATTERNS = {
50
+ 'remote_theme' => /remote_theme\s*:\s*["']?bamr87\/zer0-mistakes(?:@v?([0-9][^"'\s]*))?/,
51
+ 'hub_registry' => /theme_repo\s*:\s*["']?bamr87\/zer0-mistakes(?:@v?([0-9][^"'\s]*))?/,
52
+ 'gem_constraint' => /gem\s+["']jekyll-theme-zer0["']\s*,\s*["'][^0-9]*([0-9][^"']*)["']/,
53
+ 'theme_name' => nil,
54
+ 'path_gem' => nil
55
+ }.freeze
56
+
57
+ options = {
58
+ format: 'text', strict: false, dispatch: false, dry_run: false, version: nil
59
+ }
60
+
61
+ OptionParser.new do |o|
62
+ o.banner = 'Usage: ruby scripts/propagate.rb [options]'
63
+ o.on('--dispatch', 'Send theme-release events instead of reporting') { options[:dispatch] = true }
64
+ o.on('--dry-run', 'Show what --dispatch would send; send nothing') { options[:dry_run] = true }
65
+ o.on('--strict', 'Exit 1 when a consumer is behind the current version') { options[:strict] = true }
66
+ o.on('--format FMT', %w[text json github], 'Output format (text|json|github)') { |v| options[:format] = v }
67
+ o.on('--version VER', 'Theme version to compare against (default: version.rb)') { |v| options[:version] = v }
68
+ o.on('-h', '--help', 'Show this help') { puts o; exit 0 }
69
+ end.parse!
70
+
71
+ # -----------------------------------------------------------------------------
72
+ def theme_version
73
+ content = File.read(VERSION_RB)
74
+ content[/VERSION\s*=\s*"([^"]+)"/, 1] or abort('could not parse version.rb')
75
+ end
76
+
77
+ def gh_available?
78
+ return @gh_available unless @gh_available.nil?
79
+
80
+ @gh_available = system('command -v gh > /dev/null 2>&1')
81
+ end
82
+
83
+ def token
84
+ ENV['GH_TOKEN'] || ENV['GITHUB_TOKEN'] || ENV['LIFECYCLE_PAT']
85
+ end
86
+
87
+ def http_get(uri, headers = {})
88
+ req = Net::HTTP::Get.new(uri)
89
+ headers.each { |k, v| req[k] = v }
90
+ res = Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: 10, read_timeout: 15) do |http|
91
+ http.request(req)
92
+ end
93
+ res.is_a?(Net::HTTPSuccess) ? res.body : nil
94
+ rescue StandardError
95
+ nil
96
+ end
97
+
98
+ # Fetch one file from a consumer repo. Returns nil (never raises) when the repo,
99
+ # branch, or path is unreachable — an unreachable consumer must degrade to
100
+ # "unknown" in the report, not take the whole fan-out down.
101
+ #
102
+ # Two transports, in order of capability:
103
+ # 1. api.github.com/contents with a token — the only path that reads PRIVATE
104
+ # consumer repos, and the one CI uses.
105
+ # 2. raw.githubusercontent.com unauthenticated — public repos only.
106
+ # Never send the token to raw.githubusercontent.com: a token that is valid for
107
+ # the API but not for raw turns a public 200 into a 404, which silently reports
108
+ # every consumer as unreachable.
109
+ def fetch_file(repo, branch, path)
110
+ if gh_available?
111
+ out = `gh api "repos/#{repo}/contents/#{path}?ref=#{branch}" --jq .content 2>/dev/null`
112
+ return Base64.decode64(out) if $?.success? && !out.strip.empty?
113
+ end
114
+
115
+ if token
116
+ body = http_get(
117
+ URI("https://api.github.com/repos/#{repo}/contents/#{path}?ref=#{branch}"),
118
+ 'Authorization' => "Bearer #{token}",
119
+ 'Accept' => 'application/vnd.github.raw',
120
+ 'X-GitHub-Api-Version' => '2022-11-28'
121
+ )
122
+ return body if body
123
+ end
124
+
125
+ http_get(URI("https://raw.githubusercontent.com/#{repo}/#{branch}/#{path}"))
126
+ end
127
+
128
+ def extract_pin(kind, content)
129
+ pattern = PIN_PATTERNS[kind]
130
+ return { versioned: false, version: nil } if pattern.nil?
131
+ return { versioned: true, version: nil, missing: true } if content.nil?
132
+
133
+ match = content.match(pattern)
134
+ return { versioned: true, version: nil, missing: true } if match.nil?
135
+
136
+ # A matched pin with no captured version is a deliberate floating ref
137
+ # (`remote_theme: bamr87/zer0-mistakes`), not a parse failure.
138
+ { versioned: true, version: match[1] }
139
+ end
140
+
141
+ def compare(pinned, current)
142
+ return :floating if pinned.nil?
143
+
144
+ a = Gem::Version.new(pinned.sub(/\A[v=~> ]+/, ''))
145
+ b = Gem::Version.new(current)
146
+ return :current if a == b
147
+ return :behind if a < b
148
+
149
+ :ahead
150
+ rescue ArgumentError
151
+ :unknown
152
+ end
153
+
154
+ def dispatch(repo, event, version, dry_run)
155
+ payload = {
156
+ event_type: event,
157
+ client_payload: { version: version, theme: 'bamr87/zer0-mistakes', tag: "v#{version}" }
158
+ }
159
+
160
+ if dry_run
161
+ puts " [DRY RUN] POST repos/#{repo}/dispatches #{payload[:client_payload].to_json}"
162
+ return :dry_run
163
+ end
164
+
165
+ unless token
166
+ warn " ✗ #{repo}: no token (GH_TOKEN / GITHUB_TOKEN / LIFECYCLE_PAT) — cannot dispatch"
167
+ return :no_token
168
+ end
169
+
170
+ uri = URI("https://api.github.com/repos/#{repo}/dispatches")
171
+ req = Net::HTTP::Post.new(uri)
172
+ req['Authorization'] = "Bearer #{token}"
173
+ req['Accept'] = 'application/vnd.github+json'
174
+ req['Content-Type'] = 'application/json'
175
+ req.body = payload.to_json
176
+ res = Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: 10, read_timeout: 15) do |http|
177
+ http.request(req)
178
+ end
179
+
180
+ if res.is_a?(Net::HTTPSuccess)
181
+ :dispatched
182
+ else
183
+ warn " ✗ #{repo}: dispatch failed (HTTP #{res.code})"
184
+ :failed
185
+ end
186
+ rescue StandardError => e
187
+ warn " ✗ #{repo}: dispatch error (#{e.class})"
188
+ :failed
189
+ end
190
+
191
+ # -----------------------------------------------------------------------------
192
+ abort("registry not found: #{REGISTRY}") unless File.exist?(REGISTRY)
193
+
194
+ registry = YAML.load_file(REGISTRY)
195
+ defaults = registry['defaults'] || {}
196
+ current = options[:version] || theme_version
197
+ consumers = registry['consumers'] || []
198
+
199
+ results = consumers.map do |consumer|
200
+ repo = consumer['repo']
201
+ branch = consumer['branch'] || defaults['branch'] || 'main'
202
+
203
+ pins = (consumer['pins'] || []).map do |pin|
204
+ kind = pin['kind']
205
+ content = PIN_PATTERNS[kind].nil? ? nil : fetch_file(repo, branch, pin['file'])
206
+ info = extract_pin(kind, content)
207
+ status = if !info[:versioned] then :not_versioned
208
+ elsif info[:missing] then :unreachable
209
+ else compare(info[:version], current)
210
+ end
211
+ { 'file' => pin['file'], 'kind' => kind, 'pinned' => info[:version], 'status' => status.to_s }
212
+ end
213
+
214
+ versioned = pins.reject { |p| %w[not_versioned unreachable].include?(p['status']) }
215
+ overall = if versioned.any? { |p| p['status'] == 'behind' } then :behind
216
+ elsif versioned.any? { |p| p['status'] == 'floating' } then :floating
217
+ elsif versioned.empty? then :unknown
218
+ elsif versioned.all? { |p| p['status'] == 'current' } then :current
219
+ else :mixed
220
+ end
221
+
222
+ {
223
+ 'repo' => repo, 'mode' => consumer['mode'], 'dispatch' => consumer['dispatch'] == true,
224
+ 'status' => overall.to_s, 'pins' => pins
225
+ }
226
+ end
227
+
228
+ behind = results.select { |r| r['status'] == 'behind' || r['status'] == 'mixed' }
229
+
230
+ # -----------------------------------------------------------------------------
231
+ if options[:dispatch]
232
+ event = defaults['dispatch_event'] || 'theme-release'
233
+ puts "Dispatching #{event} v#{current} to #{results.count { |r| r['dispatch'] }} consumer(s)"
234
+ results.each do |r|
235
+ unless r['dispatch']
236
+ puts " – #{r['repo']}: skipped (dispatch: false — #{r['mode']})"
237
+ next
238
+ end
239
+ outcome = dispatch(r['repo'], event, current, options[:dry_run])
240
+ puts " ✓ #{r['repo']}: #{outcome}" if %i[dispatched dry_run].include?(outcome)
241
+ end
242
+ exit 0
243
+ end
244
+
245
+ case options[:format]
246
+ when 'json'
247
+ puts JSON.pretty_generate({ 'theme_version' => current, 'consumers' => results })
248
+ when 'github'
249
+ results.each do |r|
250
+ next unless %w[behind mixed].include?(r['status'])
251
+
252
+ stale = r['pins'].select { |p| p['status'] == 'behind' }
253
+ .map { |p| "#{p['file']} (#{p['pinned']})" }.join(', ')
254
+ puts "::warning::#{r['repo']} is behind v#{current} — #{stale}"
255
+ end
256
+ puts "::notice::#{behind.size}/#{results.size} consumers behind v#{current}"
257
+ else
258
+ puts "Theme version: #{current}"
259
+ puts
260
+ results.each do |r|
261
+ marker = { 'current' => '✓', 'behind' => '✗', 'floating' => '~', 'mixed' => '✗' }[r['status']] || '?'
262
+ puts format('%s %-38s %-22s %s', marker, r['repo'], r['mode'], r['status'])
263
+ r['pins'].each do |p|
264
+ detail = case p['status']
265
+ when 'not_versioned' then 'no version (tracked only)'
266
+ when 'unreachable' then 'unreachable — check repo/branch/path'
267
+ when 'floating' then 'floating on main'
268
+ else p['pinned']
269
+ end
270
+ puts format(' %-24s %-16s %s', p['file'], p['kind'], detail)
271
+ end
272
+ puts
273
+ end
274
+ puts "#{behind.size}/#{results.size} consumer(s) behind v#{current}"
275
+ end
276
+
277
+ exit 1 if options[:strict] && !behind.empty?