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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +386 -4
- data/_data/consumers.yml +147 -0
- data/_data/features.yml +77 -5
- data/_data/i18n/fr.yml +98 -0
- data/_data/i18n/manifest.yml +1502 -0
- data/_data/navigation/docs.yml +2 -0
- data/_data/navigation/main.yml +6 -0
- data/_data/series.yml +19 -0
- data/_data/theme-manifest.yml +348 -266
- data/_includes/README.md +21 -2
- 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/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/data-card.html +95 -0
- data/_includes/components/halfmoon.html +5 -1
- data/_includes/components/page-feedback.html +65 -2
- data/_includes/components/theme-controls-bar.html +10 -2
- data/_includes/components/theme-customizer.html +8 -2
- 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/head.html +9 -0
- 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/sidebar-config.html +21 -0
- data/_includes/navigation/sidebar-nav.html +4 -0
- data/_includes/navigation/sidebar-pagetree.html +150 -0
- data/_includes/navigation/unified-drawer.html +8 -2
- data/_includes/obsidian/full-graph.html +165 -136
- data/_layouts/404.html +260 -0
- data/_layouts/README.md +2 -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/home.html +5 -2
- data/_layouts/landing.html +9 -0
- data/_layouts/news.html +155 -31
- data/_layouts/root.html +16 -2
- data/_layouts/section.html +57 -15
- data/_sass/components/_book.scss +423 -0
- data/_sass/core/_navbar.scss +13 -0
- data/_sass/core/_obsidian.scss +286 -6
- data/_sass/theme/_backgrounds.scss +21 -8
- data/assets/css/main.scss +1 -0
- data/assets/js/auto-hide-nav.js +5 -1
- data/assets/js/halfmoon.js +26 -0
- data/assets/js/obsidian-graph.js +702 -264
- data/assets/js/obsidian-local-graph.js +161 -54
- data/assets/js/search-modal.js +4 -1
- data/scripts/bin/manifest +31 -4
- data/scripts/bin/validate +5 -1
- 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/propagate.rb +277 -0
- data/scripts/translate.rb +90 -0
- 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
|
+
}
|
data/scripts/install/doctor.sh
CHANGED
|
@@ -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
|
data/scripts/install/plan.sh
CHANGED
|
@@ -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
|
data/scripts/install/spec.sh
CHANGED
|
@@ -85,7 +85,7 @@ spec_default() {
|
|
|
85
85
|
},
|
|
86
86
|
"ai": {
|
|
87
87
|
"used": false,
|
|
88
|
-
"provider": "
|
|
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
|
-
|
|
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:-
|
|
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 // "
|
|
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:-
|
|
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)}"
|
data/scripts/install/template.sh
CHANGED
|
@@ -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?
|