jekyll-theme-zer0 1.27.0 → 1.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +812 -13
  3. data/_data/backlog.yml +356 -2
  4. data/_data/consumers.yml +252 -0
  5. data/_data/features.yml +234 -5
  6. data/_data/i18n/fr.yml +103 -0
  7. data/_data/i18n/manifest.yml +1529 -0
  8. data/_data/ingredient_densities.yml +122 -0
  9. data/_data/navigation/docs.yml +2 -0
  10. data/_data/navigation/main.yml +22 -0
  11. data/_data/recipe_courses.yml +64 -0
  12. data/_data/series.yml +19 -0
  13. data/_data/theme-manifest.yml +387 -277
  14. data/_data/ui-text.yml +8 -0
  15. data/_includes/README.md +36 -3
  16. data/_includes/analytics/google-tag-manager-body.html +11 -2
  17. data/_includes/analytics/google-tag-manager-head.html +16 -6
  18. data/_includes/analytics/posthog.html +33 -4
  19. data/_includes/components/abc-letter.html +43 -0
  20. data/_includes/components/background-customizer.html +2 -2
  21. data/_includes/components/book-card.html +42 -0
  22. data/_includes/components/book-nav.html +80 -0
  23. data/_includes/components/book-plate.html +31 -0
  24. data/_includes/components/book-toc.html +47 -0
  25. data/_includes/components/bookshelf.html +68 -0
  26. data/_includes/components/card-grid.html +60 -0
  27. data/_includes/components/cookie-consent.html +4 -4
  28. data/_includes/components/data-card.html +95 -0
  29. data/_includes/components/halfmoon.html +5 -1
  30. data/_includes/components/info-section.html +8 -3
  31. data/_includes/components/language-toggle.html +168 -21
  32. data/_includes/components/page-feedback.html +65 -2
  33. data/_includes/components/page-views-init.html +55 -0
  34. data/_includes/components/page-views.html +33 -0
  35. data/_includes/components/recipe-card.html +67 -0
  36. data/_includes/components/recipe-duration.html +50 -0
  37. data/_includes/components/recipe-grams.html +58 -0
  38. data/_includes/components/recipe-index.html +96 -0
  39. data/_includes/components/recipe-ingredients.html +90 -0
  40. data/_includes/components/recipe-meta.html +96 -0
  41. data/_includes/components/recipe-nutrition.html +57 -0
  42. data/_includes/components/recipe-qty.html +73 -0
  43. data/_includes/components/recipe-ratio.html +151 -0
  44. data/_includes/components/recipe-scaler.html +73 -0
  45. data/_includes/components/recipe-steps.html +86 -0
  46. data/_includes/components/recipe-temp.html +45 -0
  47. data/_includes/components/search-modal.html +2 -2
  48. data/_includes/components/shortcuts-modal.html +3 -0
  49. data/_includes/components/theme-controls-bar.html +10 -2
  50. data/_includes/components/theme-customizer.html +8 -2
  51. data/_includes/components/theme-info.html +8 -1
  52. data/_includes/content/jsonld-software.html +22 -3
  53. data/_includes/content/seo.html +9 -3
  54. data/_includes/core/color-mode-init.html +13 -4
  55. data/_includes/core/favicon.html +46 -0
  56. data/_includes/core/footer.html +18 -16
  57. data/_includes/core/head.html +21 -0
  58. data/_includes/core/header.html +7 -4
  59. data/_includes/custom/body-end.html +18 -0
  60. data/_includes/custom/body-start.html +17 -0
  61. data/_includes/custom/footer.html +18 -0
  62. data/_includes/custom/head.html +18 -0
  63. data/_includes/navigation/local-graph.html +28 -2
  64. data/_includes/navigation/nav-tree.html +3 -3
  65. data/_includes/navigation/section-sidebar.html +93 -11
  66. data/_includes/navigation/sidebar-config.html +21 -0
  67. data/_includes/navigation/sidebar-left.html +2 -1
  68. data/_includes/navigation/sidebar-nav.html +4 -0
  69. data/_includes/navigation/sidebar-pagetree.html +150 -0
  70. data/_includes/navigation/sidebar-right.html +2 -1
  71. data/_includes/navigation/unified-drawer.html +8 -2
  72. data/_includes/obsidian/full-graph.html +165 -136
  73. data/_includes/setup/wizard.html +173 -86
  74. data/_includes/stats/stats-metrics.html +2 -2
  75. data/_layouts/404.html +260 -0
  76. data/_layouts/README.md +4 -0
  77. data/_layouts/admin.html +2 -2
  78. data/_layouts/article.html +5 -0
  79. data/_layouts/book-abc.html +106 -0
  80. data/_layouts/book-story.html +91 -0
  81. data/_layouts/book.html +113 -0
  82. data/_layouts/collection.html +17 -5
  83. data/_layouts/cookbook.html +88 -0
  84. data/_layouts/default.html +4 -4
  85. data/_layouts/home.html +5 -2
  86. data/_layouts/landing.html +9 -0
  87. data/_layouts/news.html +155 -31
  88. data/_layouts/recipe.html +274 -0
  89. data/_layouts/root.html +38 -5
  90. data/_layouts/section.html +96 -25
  91. data/_sass/components/_book.scss +423 -0
  92. data/_sass/components/_callout.scss +1 -1
  93. data/_sass/components/_footer.scss +37 -1
  94. data/_sass/components/_page-views.scss +36 -0
  95. data/_sass/components/_recipe.scss +506 -0
  96. data/_sass/components/_setup-wizard.scss +235 -0
  97. data/_sass/components/_ui-enhancements.scss +6 -6
  98. data/_sass/core/_navbar.scss +263 -15
  99. data/_sass/core/_obsidian.scss +286 -6
  100. data/_sass/layouts/_landing.scss +2 -2
  101. data/_sass/theme/_backgrounds.scss +21 -8
  102. data/_sass/tokens/_color.scss +6 -0
  103. data/_sass/tokens/_index.scss +2 -0
  104. data/_sass/tokens/_radius.scss +21 -0
  105. data/_sass/tokens/_typography.scss +4 -0
  106. data/_sass/utilities/_focus.scss +14 -0
  107. data/assets/css/main.scss +4 -0
  108. data/assets/js/auto-hide-nav.js +5 -1
  109. data/assets/js/halfmoon.js +26 -0
  110. data/assets/js/modules/navigation/navbar.js +55 -0
  111. data/assets/js/obsidian-graph.js +702 -264
  112. data/assets/js/obsidian-local-graph.js +161 -54
  113. data/assets/js/page-views.js +372 -0
  114. data/assets/js/recipe-scaler.js +501 -0
  115. data/assets/js/search-modal.js +14 -1
  116. data/assets/js/setup-wizard.js +322 -20
  117. data/scripts/README.md +29 -0
  118. data/scripts/bin/audit-consumer +39 -7
  119. data/scripts/bin/giscus-discussions +213 -14
  120. data/scripts/bin/manifest +66 -16
  121. data/scripts/bin/validate +5 -1
  122. data/scripts/ci/agent_review_result.py +164 -0
  123. data/scripts/ci/test_agent_review_result.py +172 -0
  124. data/scripts/design-system-check.rb +170 -0
  125. data/scripts/install/README.md +47 -6
  126. data/scripts/install/ai/client.sh +302 -93
  127. data/scripts/install/ai/prompts/spec.schema.json +1 -1
  128. data/scripts/install/ai/wizard.sh +10 -5
  129. data/scripts/install/apply.sh +7 -3
  130. data/scripts/install/cli.sh +54 -4
  131. data/scripts/install/config.sh +167 -0
  132. data/scripts/install/doctor.sh +38 -0
  133. data/scripts/install/plan.sh +10 -0
  134. data/scripts/install/spec.sh +15 -7
  135. data/scripts/install/template.sh +4 -0
  136. data/scripts/lib/audit.sh +42 -2
  137. data/scripts/lint-liquid-raw.rb +137 -0
  138. data/scripts/propagate.rb +277 -0
  139. data/scripts/test/lib/run_tests.sh +2 -1
  140. data/scripts/test/lib/test_agent_review_result.sh +27 -0
  141. data/scripts/translate.rb +161 -15
  142. metadata +56 -2
@@ -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" \
data/scripts/lib/audit.sh CHANGED
@@ -205,13 +205,45 @@ detect_consumer_mode() {
205
205
  return
206
206
  fi
207
207
 
208
- if grep -qE '^remote_theme:' "$config"; then
208
+ # Match a top-level `remote_theme` key. The whitespace class before the
209
+ # colon is load-bearing: most fleet consumers write column-aligned YAML
210
+ # (`remote_theme : "bamr87/zer0-mistakes"`), which a bare
211
+ # `^remote_theme:` never matches — every one of them was silently
212
+ # misreported as gem mode, skipping the plugin checks entirely.
213
+ # `|| true` keeps a no-match grep from tripping the caller's `set -o pipefail`.
214
+ local line value
215
+ line=$(grep -m1 -E '^remote_theme[[:space:]]*:' "$config" 2>/dev/null || true)
216
+ value=$(printf '%s' "$line" \
217
+ | sed -E 's/^remote_theme[[:space:]]*:[[:space:]]*//; s/[[:space:]]*(#.*)?$//; s/^"(.*)"$/\1/; s/^'\''(.*)'\''$/\1/')
218
+
219
+ # `remote_theme: false` (or an empty value) is how a consumer turns the
220
+ # remote theme OFF and resolves the theme as a gem instead.
221
+ if [[ -n "$value" && "$value" != "false" ]]; then
209
222
  echo "remote_theme"
210
223
  else
211
224
  echo "gem"
212
225
  fi
213
226
  }
214
227
 
228
+ # detect_consumer_source <consumer_root>
229
+ # Echoes the Jekyll `source:` value from _config.yml (relative path), or empty.
230
+ #
231
+ # Jekyll's `source:` relocates the whole site tree — zer0-pages keeps every
232
+ # theme-shadowing file under `pages/`. Auditing the repo root there reports a
233
+ # falsely clean "0 overrides, 0 unique files"; the real surface only appears
234
+ # when the scan follows `source:`.
235
+ detect_consumer_source() {
236
+ local consumer_root="$1"
237
+ local config="$consumer_root/_config.yml"
238
+
239
+ [[ -f "$config" ]] || return 0
240
+
241
+ local line
242
+ line=$(grep -m1 -E '^source[[:space:]]*:' "$config" 2>/dev/null || true)
243
+ printf '%s' "$line" \
244
+ | sed -E 's/^source[[:space:]]*:[[:space:]]*//; s/[[:space:]]*(#.*)?$//; s/^"(.*)"$/\1/; s/^'\''(.*)'\''$/\1/; s#^\./##; s#/$##'
245
+ }
246
+
215
247
  # ---------------------------------------------------------------------------
216
248
  # Output helpers
217
249
  # ---------------------------------------------------------------------------
@@ -277,7 +309,15 @@ print_classification() {
277
309
  echo "::notice file=$relpath::OPTIONAL_PLUGIN (not required)" ;;
278
310
  esac
279
311
  elif [[ "$format" == "json" ]]; then
280
- echo " {\"path\":\"$relpath\",\"status\":\"$classification\"},"
312
+ # Separator goes BEFORE each entry but the first, so the array never
313
+ # ends on a dangling comma. Emitting a trailing comma per entry (the
314
+ # previous behaviour) produced `...},\n]}` — which no JSON parser
315
+ # accepts, making --format json unusable for any CI consumer.
316
+ if [[ "${JSON_ENTRY_EMITTED:-0}" == "1" ]]; then
317
+ printf ',\n'
318
+ fi
319
+ JSON_ENTRY_EMITTED=1
320
+ printf ' {"path":"%s","status":"%s"}' "$relpath" "$classification"
281
321
  else
282
322
  echo -e "${color}[$classification]${NC} $relpath"
283
323
  fi
@@ -0,0 +1,137 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # ---------------------------------------------------------------------------
5
+ # lint-liquid-raw — guard the Markdown the Pages build renders through Liquid
6
+ #
7
+ # `github-pages` ships jekyll-optional-front-matter, so EVERY non-excluded
8
+ # Markdown file in the repo becomes a page — CHANGELOG.md and the
9
+ # _design-system/ mirror included — and every one of them is run through Liquid
10
+ # before Markdown. Two failure modes follow, and neither is loud:
11
+ #
12
+ # 1. An unbalanced `{% raw %}`. Liquid's raw block swallows everything up to
13
+ # the FIRST `{% endraw %}`, so a stray opener silently eats the rest of the
14
+ # document. CHANGELOG.md carried one that consumed 945 lines while the
15
+ # build stayed green.
16
+ #
17
+ # 2. A literal `{% raw %}` / `{% endraw %}` written as documentation prose.
18
+ # It reads like text but parses as a tag, which is how (1) happens. To show
19
+ # the tag, escape it: `{% raw %}{% raw %}{% endraw %}`.
20
+ #
21
+ # A Liquid *syntax* error (e.g. `{{…}}`) is caught by the Pages workflow, which
22
+ # fails on "Liquid Warning" in the build output. This script catches the class
23
+ # the build cannot report, because swallowed text is not an error to Liquid.
24
+ #
25
+ # Usage:
26
+ # scripts/lint-liquid-raw.rb # scan the rendered Markdown surface
27
+ # scripts/lint-liquid-raw.rb PATH... # scan specific files
28
+ # scripts/lint-liquid-raw.rb --verbose # list every file scanned
29
+ # ---------------------------------------------------------------------------
30
+
31
+ ROOT = File.expand_path('..', __dir__)
32
+ TAG = /\{%-?\s*(raw|endraw)\s*-?%\}/.freeze
33
+
34
+ # The escape form: a raw block whose entire body is one raw/endraw tag, which
35
+ # is how you render the tag as text. Blanked before scanning so the literal it
36
+ # carries is not mistaken for a real tag.
37
+ ESCAPED = /\{%-?\s*raw\s*-?%\}\{%-?\s*(?:raw|endraw)\s*-?%\}\{%-?\s*endraw\s*-?%\}/.freeze
38
+
39
+ # Directories Jekyll never reads, plus the ones _config.yml excludes. Kept in
40
+ # sync with the `exclude:` list; a path missing here only costs a false
41
+ # positive, never a missed one.
42
+ SKIP_DIRS = %w[
43
+ .git .github .claude .cursor .devcontainer .frontmatter .vscode .obsidian
44
+ .jekyll-cache .sass-cache _site node_modules vendor pkg logs
45
+ docs scripts templates test tests examples submodules gemfiles docker lib
46
+ ].freeze
47
+
48
+ # True when `pos` sits inside a single-backtick code span: an odd number of
49
+ # backtick runs precede it on its own line.
50
+ def in_code_span?(text, pos)
51
+ bol = text.rindex("\n", pos - 1)
52
+ line = text[(bol ? bol + 1 : 0)...pos]
53
+ line.scan(/`+/).size.odd?
54
+ end
55
+
56
+ def scan(path)
57
+ # Blank the escape form (keeping byte offsets and newlines) so the literal
58
+ # tag it renders is not read as a tag itself.
59
+ text = File.read(path, encoding: 'UTF-8')
60
+ .gsub(ESCAPED) { |m| ' ' * m.length }
61
+ problems = []
62
+ in_raw = false
63
+ opened_at = nil
64
+ opened_in_span = false
65
+
66
+ text.to_enum(:scan, TAG).each do
67
+ match = Regexp.last_match
68
+ line = text[0, match.begin(0)].count("\n") + 1
69
+ if in_raw
70
+ # Inside a raw block every tag is literal text — nothing to check.
71
+ if match[1] == 'endraw'
72
+ if opened_in_span && line != opened_at
73
+ problems << "#{path}:#{opened_at}: `{% raw %}` inside a code span " \
74
+ "opens a block that only closes at line #{line} — " \
75
+ 'escape it to show the tag as text'
76
+ end
77
+ in_raw = false
78
+ opened_at = nil
79
+ opened_in_span = false
80
+ end
81
+ next
82
+ end
83
+
84
+ if match[1] == 'raw'
85
+ in_raw = true
86
+ opened_at = line
87
+ # `{% raw %}` inside an inline code span is the documentation-mention
88
+ # trap: it reads as text but opens a real block. Legitimate inline use
89
+ # (`{% raw %}{{ x }}{% endraw %}`) closes on the same line; a mention
90
+ # does not, and swallows everything up to the next `{% endraw %}`.
91
+ opened_in_span = in_code_span?(text, match.begin(0))
92
+ else
93
+ problems << "#{path}:#{line}: `{% endraw %}` with no matching `{% raw %}`"
94
+ end
95
+ end
96
+
97
+ if in_raw
98
+ problems << "#{path}:#{opened_at}: `{% raw %}` is never closed — " \
99
+ 'Liquid will swallow the rest of the file'
100
+ end
101
+ problems
102
+ rescue ArgumentError, Encoding::InvalidByteSequenceError => e
103
+ ["#{path}: could not read as UTF-8 (#{e.class})"]
104
+ end
105
+
106
+ def markdown_files
107
+ Dir.chdir(ROOT) do
108
+ Dir.glob('**/*.{md,markdown}', File::FNM_DOTMATCH).reject do |p|
109
+ p.split(File::SEPARATOR).any? { |seg| SKIP_DIRS.include?(seg) }
110
+ end.sort
111
+ end
112
+ end
113
+
114
+ verbose = ARGV.delete('--verbose')
115
+ targets = ARGV.empty? ? markdown_files : ARGV
116
+
117
+ problems = []
118
+ Dir.chdir(ROOT) do
119
+ targets.each do |path|
120
+ next unless File.file?(path)
121
+
122
+ warn " scanning #{path}" if verbose
123
+ problems.concat(scan(path))
124
+ end
125
+ end
126
+
127
+ if problems.empty?
128
+ puts "✓ lint-liquid-raw: #{targets.size} Markdown files, all " \
129
+ '`{% raw %}` blocks balanced'
130
+ exit 0
131
+ end
132
+
133
+ warn '✗ lint-liquid-raw: unbalanced Liquid raw blocks'
134
+ problems.each { |p| warn " #{p}" }
135
+ warn ''
136
+ warn 'To display a literal tag, escape it: `{% raw %}{% raw %}{% endraw %}`'
137
+ exit 1