jekyll-theme-zer0 1.15.0 → 1.16.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.
@@ -0,0 +1,356 @@
1
+ #!/bin/bash
2
+ # scripts/bin/sync-plugins — copy/update theme plugins into a remote_theme consumer repo
3
+ #
4
+ # When using remote_theme on GitHub Pages, Jekyll does not load plugins from
5
+ # the theme. Consumers must vendor required plugins locally. This command
6
+ # reads the theme manifest, compares SHAs, and installs or updates plugins
7
+ # in the consumer's _plugins/ directory.
8
+ #
9
+ # Usage:
10
+ # ./scripts/bin/sync-plugins [OPTIONS]
11
+ #
12
+ # --consumer-path <dir> Target consumer repo root (default: cwd)
13
+ # --theme-path <dir> Source theme path (auto-resolved if omitted)
14
+ # --plugins {required|optional|all|<name>}
15
+ # Which plugins to sync (default: required)
16
+ # --dry-run Show what would happen without making changes
17
+ # --force Overwrite plugins listed in .theme-overrides.yml
18
+ # --format {text|github} Output format (default: text)
19
+ # --verbose Debug output
20
+ # --help, -h Show this help
21
+
22
+ set -euo pipefail
23
+
24
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
25
+ LIB_DIR="$SCRIPT_DIR/../lib"
26
+ THEME_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
27
+
28
+ source "$LIB_DIR/common.sh"
29
+ source "$LIB_DIR/audit.sh"
30
+
31
+ # ---------------------------------------------------------------------------
32
+ show_usage() {
33
+ cat << 'EOF'
34
+ 🔌 Plugin Sync for zer0-mistakes remote_theme consumers
35
+
36
+ USAGE:
37
+ ./scripts/bin/sync-plugins [OPTIONS]
38
+
39
+ DESCRIPTION:
40
+ Copies theme plugins from zer0-mistakes into a remote_theme consumer
41
+ (e.g. it-journey). When using remote_theme on GitHub Pages, Jekyll does
42
+ not load plugins from the theme — consumers must vendor them locally.
43
+
44
+ This command reads the theme manifest to determine which plugins are
45
+ available, compares SHA-256 checksums, and copies new or outdated plugins
46
+ into the consumer's _plugins/ directory.
47
+
48
+ Sync state reported per plugin:
49
+ INSTALLED Plugin was missing — now copied from theme
50
+ UPDATED Plugin existed but content differed — updated to theme version
51
+ CURRENT Plugin already matches the theme version — no change needed
52
+ SKIPPED Plugin not in sync scope (e.g. optional when --plugins required)
53
+ CONFLICT Plugin differs AND is listed in .theme-overrides.yml
54
+ → intentional custom version; use --force to overwrite
55
+
56
+ OPTIONS:
57
+ --consumer-path <dir> Consumer repo root (default: current directory)
58
+ --theme-path <dir> Source theme path (auto-resolved if omitted)
59
+ --plugins <scope> Which plugins to sync:
60
+ required Required plugins only (default)
61
+ optional Optional plugins only
62
+ all Both required and optional
63
+ <name> Specific plugin, e.g. obsidian_links.rb
64
+ --dry-run Show what would happen without making changes
65
+ --force Sync even if plugin is listed in .theme-overrides.yml
66
+ --format {text|github} Output format (default: text)
67
+ --verbose Debug output
68
+ --help, -h Show this help
69
+
70
+ EXAMPLES:
71
+ # Install required plugins into a sibling consumer repo
72
+ ./scripts/bin/sync-plugins --consumer-path ../it-journey
73
+
74
+ # Preview all plugins that would be installed/updated
75
+ ./scripts/bin/sync-plugins --consumer-path ../it-journey --plugins all --dry-run
76
+
77
+ # Sync only the Obsidian wiki-link plugin
78
+ ./scripts/bin/sync-plugins --consumer-path ../it-journey --plugins obsidian_links.rb
79
+
80
+ # Update optional plugins too, forcing over any custom versions
81
+ ./scripts/bin/sync-plugins --consumer-path ../it-journey --plugins all --force
82
+
83
+ # GitHub Actions annotation format
84
+ ./scripts/bin/sync-plugins --consumer-path ../it-journey --format github
85
+ EOF
86
+ }
87
+
88
+ # ---------------------------------------------------------------------------
89
+ # Argument parsing
90
+ # ---------------------------------------------------------------------------
91
+ CONSUMER_PATH=""
92
+ THEME_PATH_OVERRIDE=""
93
+ PLUGINS_SCOPE="required"
94
+ DRY_RUN_FLAG=false
95
+ FORCE=false
96
+ FORMAT="text"
97
+ VERBOSE_FLAG=false
98
+ SHOW_HELP=false
99
+
100
+ while [[ $# -gt 0 ]]; do
101
+ case "$1" in
102
+ --consumer-path) shift; CONSUMER_PATH="$1" ;;
103
+ --theme-path) shift; THEME_PATH_OVERRIDE="$1" ;;
104
+ --plugins) shift; PLUGINS_SCOPE="$1" ;;
105
+ --dry-run) DRY_RUN_FLAG=true ;;
106
+ --force) FORCE=true ;;
107
+ --format) shift; FORMAT="$1" ;;
108
+ --verbose) VERBOSE_FLAG=true ;;
109
+ --help|-h) SHOW_HELP=true ;;
110
+ *) warn "Unknown option: $1" ;;
111
+ esac
112
+ shift
113
+ done
114
+
115
+ [[ "$SHOW_HELP" == "true" ]] && { show_usage; exit 0; }
116
+ export VERBOSE="$VERBOSE_FLAG"
117
+
118
+ # ---------------------------------------------------------------------------
119
+ # Resolve paths
120
+ # ---------------------------------------------------------------------------
121
+ CONSUMER_PATH="${CONSUMER_PATH:-$PWD}"
122
+ CONSUMER_PATH="$(cd "$CONSUMER_PATH" && pwd)"
123
+
124
+ if [[ -n "$THEME_PATH_OVERRIDE" ]]; then
125
+ RESOLVED_THEME="$(cd "$THEME_PATH_OVERRIDE" && pwd)"
126
+ else
127
+ RESOLVED_THEME="$THEME_ROOT"
128
+ fi
129
+
130
+ MANIFEST="$RESOLVED_THEME/_data/theme-manifest.yml"
131
+ if [[ ! -f "$MANIFEST" ]]; then
132
+ error "Manifest not found: $MANIFEST — run ./scripts/bin/manifest first"
133
+ fi
134
+
135
+ debug "Consumer: $CONSUMER_PATH"
136
+ debug "Theme: $RESOLVED_THEME"
137
+ debug "Manifest: $MANIFEST"
138
+
139
+ # ---------------------------------------------------------------------------
140
+ # Load override manifest (detects CONFLICT — consumer intentionally differs)
141
+ # ---------------------------------------------------------------------------
142
+ OVERRIDES_FILE="$CONSUMER_PATH/.theme-overrides.yml"
143
+ OVERRIDE_PATHS_TMP=$(mktemp)
144
+ trap 'rm -f "$OVERRIDE_PATHS_TMP"' EXIT
145
+
146
+ if [[ -f "$OVERRIDES_FILE" ]]; then
147
+ overrides_list "$OVERRIDES_FILE" > "$OVERRIDE_PATHS_TMP"
148
+ debug "Loaded overrides from $OVERRIDES_FILE"
149
+ fi
150
+
151
+ # ---------------------------------------------------------------------------
152
+ # Build the list of plugins to process based on --plugins scope
153
+ # ---------------------------------------------------------------------------
154
+ REQUIRED_PLUGINS=()
155
+ OPTIONAL_PLUGINS=()
156
+
157
+ while IFS= read -r line; do
158
+ [[ -n "$line" ]] && REQUIRED_PLUGINS+=("$line")
159
+ done < <(manifest_list "$MANIFEST" "required_plugin_paths")
160
+
161
+ while IFS= read -r line; do
162
+ [[ -n "$line" ]] && OPTIONAL_PLUGINS+=("$line")
163
+ done < <(manifest_list "$MANIFEST" "optional_plugin_paths")
164
+
165
+ # Legacy fallback: if manifest doesn't have the split keys, use plugin_paths as required
166
+ if [[ ${#REQUIRED_PLUGINS[@]} -eq 0 && ${#OPTIONAL_PLUGINS[@]} -eq 0 ]]; then
167
+ while IFS= read -r line; do
168
+ [[ -n "$line" ]] && REQUIRED_PLUGINS+=("$line")
169
+ done < <(manifest_list "$MANIFEST" "plugin_paths")
170
+ fi
171
+
172
+ # Default hardcoded fallback if manifest has no plugin entries at all
173
+ if [[ ${#REQUIRED_PLUGINS[@]} -eq 0 && ${#OPTIONAL_PLUGINS[@]} -eq 0 ]]; then
174
+ REQUIRED_PLUGINS=(
175
+ "_plugins/preview_image_generator.rb"
176
+ "_plugins/obsidian_links.rb"
177
+ )
178
+ OPTIONAL_PLUGINS=(
179
+ "_plugins/admin_page_urls.rb"
180
+ "_plugins/content_statistics_generator.rb"
181
+ "_plugins/theme_version.rb"
182
+ )
183
+ fi
184
+
185
+ TARGET_PLUGINS=()
186
+ SKIPPED_PLUGINS=()
187
+
188
+ case "$PLUGINS_SCOPE" in
189
+ required)
190
+ [[ ${#REQUIRED_PLUGINS[@]} -gt 0 ]] && TARGET_PLUGINS+=("${REQUIRED_PLUGINS[@]}")
191
+ [[ ${#OPTIONAL_PLUGINS[@]} -gt 0 ]] && SKIPPED_PLUGINS+=("${OPTIONAL_PLUGINS[@]}")
192
+ ;;
193
+ optional)
194
+ [[ ${#OPTIONAL_PLUGINS[@]} -gt 0 ]] && TARGET_PLUGINS+=("${OPTIONAL_PLUGINS[@]}")
195
+ [[ ${#REQUIRED_PLUGINS[@]} -gt 0 ]] && SKIPPED_PLUGINS+=("${REQUIRED_PLUGINS[@]}")
196
+ ;;
197
+ all)
198
+ [[ ${#REQUIRED_PLUGINS[@]} -gt 0 ]] && TARGET_PLUGINS+=("${REQUIRED_PLUGINS[@]}")
199
+ [[ ${#OPTIONAL_PLUGINS[@]} -gt 0 ]] && TARGET_PLUGINS+=("${OPTIONAL_PLUGINS[@]}")
200
+ ;;
201
+ *)
202
+ # Specific plugin name — accept with or without path prefix
203
+ needle="$PLUGINS_SCOPE"
204
+ [[ "$needle" != _plugins/* ]] && needle="_plugins/$needle"
205
+ # Warn if not found in manifest
206
+ found=false
207
+ for p in "${REQUIRED_PLUGINS[@]:-}" "${OPTIONAL_PLUGINS[@]:-}"; do
208
+ if [[ "$p" == "$needle" || "$(basename "$p")" == "$(basename "$needle")" ]]; then
209
+ found=true
210
+ needle="$p" # use the manifest-canonical path
211
+ break
212
+ fi
213
+ done
214
+ [[ "$found" == "false" ]] && warn "Plugin '$PLUGINS_SCOPE' not in manifest — attempting anyway"
215
+ TARGET_PLUGINS=("$needle")
216
+ ;;
217
+ esac
218
+
219
+ # ---------------------------------------------------------------------------
220
+ # Output helpers
221
+ # ---------------------------------------------------------------------------
222
+ print_result() {
223
+ local state="$1" # INSTALLED | UPDATED | CURRENT | SKIPPED | CONFLICT
224
+ local relpath="$2"
225
+ local note="${3:-}"
226
+ case "$state" in
227
+ INSTALLED)
228
+ if [[ "$FORMAT" == "github" ]]; then
229
+ echo "::notice file=$relpath::INSTALLED plugin from theme"
230
+ else
231
+ echo -e "${GREEN}[INSTALLED]${NC} $relpath${note:+ ($note)}"
232
+ fi ;;
233
+ UPDATED)
234
+ if [[ "$FORMAT" == "github" ]]; then
235
+ echo "::notice file=$relpath::UPDATED plugin from theme"
236
+ else
237
+ echo -e "${CYAN}[UPDATED]${NC} $relpath${note:+ ($note)}"
238
+ fi ;;
239
+ CURRENT)
240
+ if [[ "$FORMAT" == "github" ]]; then
241
+ echo "::debug file=$relpath::CURRENT"
242
+ else
243
+ echo -e "${GREEN}[CURRENT]${NC} $relpath"
244
+ fi ;;
245
+ SKIPPED)
246
+ if [[ "$FORMAT" == "github" ]]; then
247
+ echo "::debug file=$relpath::SKIPPED${note:+ ($note)}"
248
+ else
249
+ echo -e " [SKIPPED] $relpath${note:+ ($note)}"
250
+ fi ;;
251
+ CONFLICT)
252
+ if [[ "$FORMAT" == "github" ]]; then
253
+ echo "::warning file=$relpath::CONFLICT - listed in .theme-overrides.yml; use --force to overwrite"
254
+ else
255
+ echo -e "${YELLOW}[CONFLICT]${NC} $relpath (listed in .theme-overrides.yml — use --force to overwrite)"
256
+ fi ;;
257
+ esac
258
+ }
259
+
260
+ # ---------------------------------------------------------------------------
261
+ # Header
262
+ # ---------------------------------------------------------------------------
263
+ info "Theme plugin sync"
264
+ info " Consumer : $CONSUMER_PATH"
265
+ info " Theme : $RESOLVED_THEME"
266
+ info " Scope : $PLUGINS_SCOPE"
267
+ [[ "$DRY_RUN_FLAG" == "true" ]] && info " Mode : dry-run (no files written)"
268
+ [[ "$FORCE" == "true" ]] && info " Force : yes (overrides respected only by omission)"
269
+ echo ""
270
+
271
+ # ---------------------------------------------------------------------------
272
+ # Sync loop
273
+ # ---------------------------------------------------------------------------
274
+ COUNT_INSTALLED=0
275
+ COUNT_UPDATED=0
276
+ COUNT_CURRENT=0
277
+ COUNT_CONFLICT=0
278
+ COUNT_SKIPPED=0
279
+
280
+ for plugin_rel in "${TARGET_PLUGINS[@]}"; do
281
+ theme_src="$RESOLVED_THEME/$plugin_rel"
282
+ consumer_dst="$CONSUMER_PATH/$plugin_rel"
283
+
284
+ if [[ ! -f "$theme_src" ]]; then
285
+ warn "Plugin not found in theme: $plugin_rel — skipping"
286
+ COUNT_SKIPPED=$((COUNT_SKIPPED + 1))
287
+ continue
288
+ fi
289
+
290
+ theme_sha=$(sha256_file "$theme_src")
291
+
292
+ if [[ ! -f "$consumer_dst" ]]; then
293
+ # ── INSTALL ──────────────────────────────────────────────────────────
294
+ if [[ "$DRY_RUN_FLAG" == "true" ]]; then
295
+ print_result "INSTALLED" "$plugin_rel" "dry-run"
296
+ else
297
+ mkdir -p "$(dirname "$consumer_dst")"
298
+ cp "$theme_src" "$consumer_dst"
299
+ print_result "INSTALLED" "$plugin_rel"
300
+ fi
301
+ COUNT_INSTALLED=$((COUNT_INSTALLED + 1))
302
+ else
303
+ consumer_sha=$(sha256_file "$consumer_dst")
304
+
305
+ if [[ "$theme_sha" == "$consumer_sha" ]]; then
306
+ # ── CURRENT ──────────────────────────────────────────────────────
307
+ print_result "CURRENT" "$plugin_rel"
308
+ COUNT_CURRENT=$((COUNT_CURRENT + 1))
309
+ else
310
+ # Content differs — check for intentional override
311
+ if grep -qxF "$plugin_rel" "$OVERRIDE_PATHS_TMP" 2>/dev/null \
312
+ && [[ "$FORCE" == "false" ]]; then
313
+ # ── CONFLICT ─────────────────────────────────────────────────
314
+ print_result "CONFLICT" "$plugin_rel"
315
+ COUNT_CONFLICT=$((COUNT_CONFLICT + 1))
316
+ else
317
+ # ── UPDATE ───────────────────────────────────────────────────
318
+ if [[ "$DRY_RUN_FLAG" == "true" ]]; then
319
+ print_result "UPDATED" "$plugin_rel" "dry-run"
320
+ else
321
+ cp "$theme_src" "$consumer_dst"
322
+ print_result "UPDATED" "$plugin_rel"
323
+ fi
324
+ COUNT_UPDATED=$((COUNT_UPDATED + 1))
325
+ fi
326
+ fi
327
+ fi
328
+ done
329
+
330
+ # Show skipped scope plugins at verbose level
331
+ if [[ "$VERBOSE_FLAG" == "true" && ${#SKIPPED_PLUGINS[@]} -gt 0 ]]; then
332
+ for plugin_rel in "${SKIPPED_PLUGINS[@]}"; do
333
+ print_result "SKIPPED" "$plugin_rel" "out of scope (--plugins $PLUGINS_SCOPE)"
334
+ done
335
+ fi
336
+
337
+ # ---------------------------------------------------------------------------
338
+ # Summary
339
+ # ---------------------------------------------------------------------------
340
+ echo ""
341
+ info "--- Summary ---"
342
+ [[ "$COUNT_INSTALLED" -gt 0 ]] && info "Installed: $COUNT_INSTALLED"
343
+ [[ "$COUNT_UPDATED" -gt 0 ]] && info "Updated: $COUNT_UPDATED"
344
+ [[ "$COUNT_CURRENT" -gt 0 ]] && info "Current: $COUNT_CURRENT"
345
+ [[ "$COUNT_CONFLICT" -gt 0 ]] && warn "Conflicts: $COUNT_CONFLICT (use --force to overwrite)"
346
+ [[ "$COUNT_SKIPPED" -gt 0 ]] && info "Skipped: $COUNT_SKIPPED"
347
+
348
+ TOTAL_CHANGES=$((COUNT_INSTALLED + COUNT_UPDATED))
349
+ echo ""
350
+ if [[ "$DRY_RUN_FLAG" == "true" && "$TOTAL_CHANGES" -gt 0 ]]; then
351
+ info "$TOTAL_CHANGES plugin(s) would be synced (dry-run — re-run without --dry-run to apply)"
352
+ elif [[ "$TOTAL_CHANGES" -gt 0 ]]; then
353
+ success "$TOTAL_CHANGES plugin(s) synced to $CONSUMER_PATH/_plugins/"
354
+ else
355
+ success "All in-scope plugins are current — nothing to do"
356
+ fi
data/scripts/bin/validate CHANGED
@@ -380,7 +380,7 @@ if File.file?('_config_secrets_local.yml')
380
380
  assert(!tracked, '_config_secrets_local.yml must remain untracked and ignored')
381
381
  end
382
382
 
383
- ignored_dirs = %w[.git _site node_modules vendor .jekyll-cache .sass-cache]
383
+ ignored_dirs = %w[.git _site node_modules vendor .jekyll-cache .sass-cache test/fixtures]
384
384
  config_like_files = []
385
385
 
386
386
  Find.find('.') do |path|
@@ -0,0 +1,284 @@
1
+ #!/bin/bash
2
+ # audit.sh — shared helpers for theme-consumer file auditing
3
+ # Source this from scripts/bin/audit-consumer and scripts/bin/manifest.
4
+ # Requires: common.sh to already be sourced.
5
+
6
+ # ---------------------------------------------------------------------------
7
+ # SHA-256 helpers (cross-platform: macOS sha256sum vs GNU sha256sum)
8
+ # ---------------------------------------------------------------------------
9
+ sha256_file() {
10
+ local file="$1"
11
+ if command -v sha256sum >/dev/null 2>&1; then
12
+ sha256sum "$file" | awk '{print $1}'
13
+ else
14
+ shasum -a 256 "$file" | awk '{print $1}'
15
+ fi
16
+ }
17
+
18
+ # ---------------------------------------------------------------------------
19
+ # Manifest parsing — reads _data/theme-manifest.yml via awk (no yq needed)
20
+ # ---------------------------------------------------------------------------
21
+ # Returns the value of a top-level scalar key in a YAML file.
22
+ manifest_scalar() {
23
+ local manifest_file="$1"
24
+ local key="$2"
25
+ awk -v k="$key" '
26
+ /^[a-zA-Z_][a-zA-Z0-9_]*:/ {
27
+ match($0, /^([a-zA-Z_][a-zA-Z0-9_]*): *(.*)/, arr)
28
+ if (arr[1] == k) { gsub(/^[[:space:]"'"'"']+|[[:space:]"'"'"']+$/, "", arr[2]); print arr[2]; exit }
29
+ }
30
+ ' "$manifest_file"
31
+ }
32
+
33
+ # Returns each item in a top-level YAML list (- item entries).
34
+ manifest_list() {
35
+ local manifest_file="$1"
36
+ local key="$2"
37
+ awk -v k="$key" '
38
+ $0 ~ "^" k ":[ ]*$" { in_block=1; next }
39
+ in_block && /^[[:space:]]*- / {
40
+ line=$0; gsub(/^[[:space:]]*- |[[:space:]]*$/, "", line)
41
+ # strip optional inline comment
42
+ sub(/ *#.*$/, "", line)
43
+ if (line != "") print line
44
+ }
45
+ in_block && /^[a-zA-Z_]/ { in_block=0 }
46
+ ' "$manifest_file"
47
+ }
48
+
49
+ # Read the per-file checksum section from the manifest.
50
+ # Emits lines of: <relpath> <sha256>
51
+ manifest_checksums() {
52
+ local manifest_file="$1"
53
+ awk '
54
+ /^file_checksums:/ { in_block=1; next }
55
+ in_block && /^ [^ ]/ {
56
+ # lines look like: _layouts/default.html: "abc123..."
57
+ line=$0; gsub(/^[[:space:]]+/, "", line)
58
+ split(line, arr, ": ")
59
+ path=arr[1]; sha=arr[2]
60
+ gsub(/"/, "", sha); gsub(/[[:space:]]/, "", sha)
61
+ if (path != "" && sha != "") print path " " sha
62
+ }
63
+ in_block && /^[a-zA-Z_]/ { in_block=0 }
64
+ ' "$manifest_file"
65
+ }
66
+
67
+ # ---------------------------------------------------------------------------
68
+ # Override manifest parsing (.theme-overrides.yml in consumer root)
69
+ # ---------------------------------------------------------------------------
70
+ # Returns each justified override path (under overrides: list).
71
+ overrides_list() {
72
+ local overrides_file="$1"
73
+ awk '
74
+ /^overrides:/ { in_block=1; next }
75
+ in_block && /^ - path: / {
76
+ path=$0; gsub(/^.*path: */, "", path); gsub(/[[:space:]]*$/, "", path)
77
+ print path
78
+ }
79
+ in_block && /^[a-zA-Z_]/ { in_block=0 }
80
+ ' "$overrides_file" | tr -d '"'"'"
81
+ }
82
+
83
+ # ---------------------------------------------------------------------------
84
+ # Classification logic
85
+ # ---------------------------------------------------------------------------
86
+ # classify_file <theme_path> <consumer_path> <relpath> <override_paths_file>
87
+ # Emits one of: IDENTICAL DIFFERS_JUSTIFIED DIFFERS_UNJUSTIFIED UNIQUE MISSING_LOCALLY
88
+ classify_file() {
89
+ local theme_root="$1"
90
+ local consumer_root="$2"
91
+ local relpath="$3"
92
+ local override_paths_file="$4" # temp file listing justified paths (one per line)
93
+
94
+ local theme_file="$theme_root/$relpath"
95
+ local consumer_file="$consumer_root/$relpath"
96
+
97
+ # File only in consumer (theme doesn't have it → unique to consumer)
98
+ if [[ ! -f "$theme_file" ]]; then
99
+ echo "UNIQUE"
100
+ return
101
+ fi
102
+
103
+ # File missing in consumer
104
+ if [[ ! -f "$consumer_file" ]]; then
105
+ echo "MISSING_LOCALLY"
106
+ return
107
+ fi
108
+
109
+ # Compare content
110
+ local theme_sha consumer_sha
111
+ theme_sha=$(sha256_file "$theme_file")
112
+ consumer_sha=$(sha256_file "$consumer_file")
113
+
114
+ if [[ "$theme_sha" == "$consumer_sha" ]]; then
115
+ echo "IDENTICAL"
116
+ return
117
+ fi
118
+
119
+ # Files differ — check if justified
120
+ if grep -qxF "$relpath" "$override_paths_file" 2>/dev/null; then
121
+ echo "DIFFERS_JUSTIFIED"
122
+ else
123
+ echo "DIFFERS_UNJUSTIFIED"
124
+ fi
125
+ }
126
+
127
+ # classify_plugin <plugin_relpath> <consumer_root> <mode>
128
+ # mode: gem | remote_theme
129
+ # Emits: MISSING_PLUGIN STALE_PLUGIN OK NOT_REQUIRED
130
+ classify_plugin() {
131
+ local plugin_relpath="$1"
132
+ local consumer_root="$2"
133
+ local mode="$3"
134
+ local theme_root="$4"
135
+
136
+ local consumer_plugin="$consumer_root/$plugin_relpath"
137
+
138
+ if [[ "$mode" != "remote_theme" ]]; then
139
+ # Gem mode: theme loads plugins itself — consumer doesn't need a copy
140
+ echo "NOT_REQUIRED"
141
+ return
142
+ fi
143
+
144
+ if [[ ! -f "$consumer_plugin" ]]; then
145
+ echo "MISSING_PLUGIN"
146
+ return
147
+ fi
148
+
149
+ # Check staleness
150
+ local theme_plugin="$theme_root/$plugin_relpath"
151
+ if [[ -f "$theme_plugin" ]]; then
152
+ local theme_sha consumer_sha
153
+ theme_sha=$(sha256_file "$theme_plugin")
154
+ consumer_sha=$(sha256_file "$consumer_plugin")
155
+ if [[ "$theme_sha" != "$consumer_sha" ]]; then
156
+ echo "STALE_PLUGIN"
157
+ return
158
+ fi
159
+ fi
160
+
161
+ echo "OK"
162
+ }
163
+
164
+ # ---------------------------------------------------------------------------
165
+ # Theme-path auto-resolution
166
+ # ---------------------------------------------------------------------------
167
+ resolve_theme_path() {
168
+ local mode="$1"
169
+ local consumer_root="$2"
170
+ local sibling_theme_root="${3:-}" # optional explicit path
171
+
172
+ if [[ -n "$sibling_theme_root" ]]; then
173
+ echo "$sibling_theme_root"
174
+ return
175
+ fi
176
+
177
+ if [[ "$mode" == "gem" ]]; then
178
+ local gem_path
179
+ gem_path=$(cd "$consumer_root" && bundle show jekyll-theme-zer0 2>/dev/null || true)
180
+ if [[ -n "$gem_path" && -d "$gem_path" ]]; then
181
+ echo "$gem_path"
182
+ return
183
+ fi
184
+ fi
185
+
186
+ # remote_theme mode or gem resolution failed: check work/theme-cache/
187
+ local cache_dir="$consumer_root/work/theme-cache/zer0-mistakes"
188
+ if [[ -d "$cache_dir" ]]; then
189
+ echo "$cache_dir"
190
+ return
191
+ fi
192
+
193
+ echo ""
194
+ }
195
+
196
+ # ---------------------------------------------------------------------------
197
+ # Detect consumer mode from _config.yml
198
+ # ---------------------------------------------------------------------------
199
+ detect_consumer_mode() {
200
+ local consumer_root="$1"
201
+ local config="$consumer_root/_config.yml"
202
+
203
+ if [[ ! -f "$config" ]]; then
204
+ echo "unknown"
205
+ return
206
+ fi
207
+
208
+ if grep -qE '^remote_theme:' "$config"; then
209
+ echo "remote_theme"
210
+ else
211
+ echo "gem"
212
+ fi
213
+ }
214
+
215
+ # ---------------------------------------------------------------------------
216
+ # Output helpers
217
+ # ---------------------------------------------------------------------------
218
+ print_classification() {
219
+ local classification="$1"
220
+ local relpath="$2"
221
+ local format="${3:-text}"
222
+
223
+ case "$classification" in
224
+ IDENTICAL)
225
+ local color="${GREEN}"
226
+ local icon="✓"
227
+ ;;
228
+ DIFFERS_JUSTIFIED)
229
+ local color="${CYAN}"
230
+ local icon="~"
231
+ ;;
232
+ DIFFERS_UNJUSTIFIED)
233
+ local color="${RED}"
234
+ local icon="✗"
235
+ ;;
236
+ UNIQUE)
237
+ local color="${BLUE}"
238
+ local icon="+"
239
+ ;;
240
+ MISSING_LOCALLY)
241
+ local color="${YELLOW}"
242
+ local icon="?"
243
+ ;;
244
+ MISSING_PLUGIN)
245
+ local color="${RED}"
246
+ local icon="!"
247
+ ;;
248
+ STALE_PLUGIN)
249
+ local color="${YELLOW}"
250
+ local icon="~"
251
+ ;;
252
+ OK)
253
+ local color="${GREEN}"
254
+ local icon="✓"
255
+ ;;
256
+ NOT_REQUIRED)
257
+ local color="${BLUE}"
258
+ local icon="-"
259
+ ;;
260
+ OPTIONAL_PLUGIN)
261
+ local color="${CYAN}"
262
+ local icon="?"
263
+ ;;
264
+ *)
265
+ local color="${NC}"
266
+ local icon="?"
267
+ ;;
268
+ esac
269
+
270
+ if [[ "$format" == "github" ]]; then
271
+ case "$classification" in
272
+ DIFFERS_UNJUSTIFIED|MISSING_PLUGIN)
273
+ echo "::warning file=$relpath::$classification" ;;
274
+ STALE_PLUGIN)
275
+ echo "::notice file=$relpath::STALE_PLUGIN" ;;
276
+ OPTIONAL_PLUGIN)
277
+ echo "::notice file=$relpath::OPTIONAL_PLUGIN (not required)" ;;
278
+ esac
279
+ elif [[ "$format" == "json" ]]; then
280
+ echo " {\"path\":\"$relpath\",\"status\":\"$classification\"},"
281
+ else
282
+ echo -e "${color}[$classification]${NC} $relpath"
283
+ fi
284
+ }
@@ -437,8 +437,10 @@ parse_file_frontmatter_all() {
437
437
 
438
438
  ruby -ryaml -rdate -e '
439
439
  path = ARGV[0]
440
- # Extract frontmatter block (between first pair of --- on their own lines)
441
- content = File.read(path)
440
+ # Extract frontmatter block (between first pair of --- on their own lines).
441
+ # Explicit UTF-8: default external encoding is US-ASCII without a UTF-8
442
+ # locale, and posts contain multibyte characters (T-015 bug class).
443
+ content = File.read(path, encoding: "UTF-8")
442
444
  unless content =~ /\A---\s*\n(.*?)\n---\s*$/m
443
445
  puts "__NO_FRONTMATTER__"
444
446
  exit 0
@@ -22,6 +22,9 @@ assert_true "(cd '$REPO_ROOT' && LC_ALL=C LANG=C ruby scripts/generate-roadmap.r
22
22
  assert_true "(cd '$REPO_ROOT' && LC_ALL=C LANG=C ruby scripts/sync-backlog.rb --check >/dev/null 2>&1)" \
23
23
  "sync-backlog.rb --check survives a C locale"
24
24
 
25
+ assert_true "(cd '$REPO_ROOT' && LC_ALL=C LANG=C ./scripts/lint-pages --strict >/dev/null 2>&1)" \
26
+ "lint-pages --strict survives a C locale"
27
+
25
28
  # validate --quick covers the package.json read in scripts/bin/validate
26
29
  assert_true "(cd '$REPO_ROOT' && LC_ALL=C LANG=C ./scripts/bin/validate --quick >/dev/null 2>&1)" \
27
30
  "validate --quick survives a C locale"