jekyll-theme-zer0 0.22.0 → 0.22.19

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 (95) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +236 -0
  3. data/README.md +66 -19
  4. data/_data/navigation/admin.yml +53 -0
  5. data/_data/theme_backgrounds.yml +121 -0
  6. data/_includes/components/admin-tabs.html +59 -0
  7. data/_includes/components/analytics-dashboard.html +232 -0
  8. data/_includes/components/background-customizer.html +159 -0
  9. data/_includes/components/background-settings.html +137 -0
  10. data/_includes/components/collection-manager.html +151 -0
  11. data/_includes/components/component-showcase.html +452 -0
  12. data/_includes/components/config-editor.html +207 -0
  13. data/_includes/components/config-viewer.html +479 -0
  14. data/_includes/components/env-dashboard.html +154 -0
  15. data/_includes/components/feature-card.html +94 -0
  16. data/_includes/components/info-section.html +172 -149
  17. data/_includes/components/js-cdn.html +4 -1
  18. data/_includes/components/nav-editor.html +99 -0
  19. data/_includes/components/setup-banner.html +28 -0
  20. data/_includes/components/setup-check.html +53 -0
  21. data/_includes/components/svg-background.html +42 -0
  22. data/_includes/components/theme-customizer.html +46 -0
  23. data/_includes/content/seo.html +68 -135
  24. data/_includes/core/footer.html +1 -1
  25. data/_includes/core/head.html +3 -2
  26. data/_includes/core/header.html +14 -7
  27. data/_includes/landing/landing-install-cards.html +18 -7
  28. data/_includes/navigation/admin-nav.html +95 -0
  29. data/_includes/navigation/navbar.html +43 -5
  30. data/_includes/navigation/sidebar-left.html +1 -1
  31. data/_includes/setup/wizard.html +330 -0
  32. data/_layouts/admin.html +166 -0
  33. data/_layouts/landing.html +23 -9
  34. data/_layouts/root.html +12 -6
  35. data/_layouts/setup.html +73 -0
  36. data/_plugins/preview_image_generator.rb +26 -12
  37. data/_sass/core/_navbar.scss +2 -2
  38. data/_sass/custom.scss +28 -6
  39. data/_sass/theme/_background-mixins.scss +95 -0
  40. data/_sass/theme/_backgrounds.scss +156 -0
  41. data/_sass/theme/_color-modes.scss +2 -1
  42. data/assets/backgrounds/gradients/air.svg +15 -0
  43. data/assets/backgrounds/gradients/aqua.svg +15 -0
  44. data/assets/backgrounds/gradients/contrast.svg +15 -0
  45. data/assets/backgrounds/gradients/dark.svg +15 -0
  46. data/assets/backgrounds/gradients/dirt.svg +15 -0
  47. data/assets/backgrounds/gradients/mint.svg +15 -0
  48. data/assets/backgrounds/gradients/neon.svg +15 -0
  49. data/assets/backgrounds/gradients/plum.svg +15 -0
  50. data/assets/backgrounds/gradients/sunrise.svg +15 -0
  51. data/assets/backgrounds/noise/air.svg +8 -0
  52. data/assets/backgrounds/noise/aqua.svg +8 -0
  53. data/assets/backgrounds/noise/contrast.svg +8 -0
  54. data/assets/backgrounds/noise/dark.svg +8 -0
  55. data/assets/backgrounds/noise/dirt.svg +8 -0
  56. data/assets/backgrounds/noise/mint.svg +8 -0
  57. data/assets/backgrounds/noise/neon.svg +8 -0
  58. data/assets/backgrounds/noise/plum.svg +8 -0
  59. data/assets/backgrounds/noise/sunrise.svg +8 -0
  60. data/assets/backgrounds/patterns/air.svg +7 -0
  61. data/assets/backgrounds/patterns/aqua.svg +7 -0
  62. data/assets/backgrounds/patterns/contrast.svg +4 -0
  63. data/assets/backgrounds/patterns/dark.svg +5 -0
  64. data/assets/backgrounds/patterns/dirt.svg +5 -0
  65. data/assets/backgrounds/patterns/mint.svg +6 -0
  66. data/assets/backgrounds/patterns/neon.svg +6 -0
  67. data/assets/backgrounds/patterns/plum.svg +6 -0
  68. data/assets/backgrounds/patterns/sunrise.svg +5 -0
  69. data/assets/js/background-customizer.js +73 -0
  70. data/assets/js/code-copy.js +18 -47
  71. data/assets/js/config-utility.js +307 -0
  72. data/assets/js/nav-editor.js +39 -0
  73. data/assets/js/palette-generator.js +415 -0
  74. data/assets/js/search-modal.js +31 -11
  75. data/assets/js/setup-wizard.js +306 -0
  76. data/assets/js/skin-editor.js +645 -0
  77. data/assets/js/theme-customizer.js +102 -0
  78. data/assets/js/ui-enhancements.js +15 -24
  79. data/assets/vendor/bootstrap/css/bootstrap.min.css +1 -0
  80. data/assets/vendor/bootstrap/js/bootstrap.bundle.min.js +1 -0
  81. data/scripts/README.md +45 -0
  82. data/scripts/features/generate-preview-images +297 -7
  83. data/scripts/features/install-preview-generator +51 -33
  84. data/scripts/fork-cleanup.sh +92 -19
  85. data/scripts/github-setup.sh +284 -0
  86. data/scripts/init_setup.sh +0 -1
  87. data/scripts/lib/frontmatter.sh +543 -0
  88. data/scripts/lib/migrate.sh +265 -0
  89. data/scripts/lib/preview_generator.py +607 -32
  90. data/scripts/lint-pages +505 -0
  91. data/scripts/migrate.sh +201 -0
  92. data/scripts/platform/setup-linux.sh +244 -0
  93. data/scripts/platform/setup-macos.sh +187 -0
  94. data/scripts/platform/setup-wsl.sh +196 -0
  95. metadata +71 -6
@@ -0,0 +1,543 @@
1
+ #!/bin/bash
2
+
3
+ # =========================================================================
4
+ # Frontmatter Validation Library
5
+ # =========================================================================
6
+ # Config-aware shared functions for frontmatter validation.
7
+ # All schema data is read from YAML config files — nothing hardcoded.
8
+ #
9
+ # Requires: ruby (for YAML parsing), common.sh
10
+ #
11
+ # Usage: source "$(dirname "$0")/lib/frontmatter.sh"
12
+ # =========================================================================
13
+
14
+ # Source common utilities if not already loaded
15
+ FRONTMATTER_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
16
+ if ! declare -f debug &>/dev/null; then
17
+ source "$FRONTMATTER_LIB_DIR/common.sh"
18
+ fi
19
+
20
+ # -------------------------------------------------------------------------
21
+ # Configuration paths (overridable via environment)
22
+ # -------------------------------------------------------------------------
23
+ FRONTMATTER_SCHEMA_PATH="${FRONTMATTER_SCHEMA_PATH:-.github/config/frontmatter_schema.yml}"
24
+ CONTENT_RULES_PATH="${CONTENT_RULES_PATH:-.github/config/content_rules.yml}"
25
+
26
+ # -------------------------------------------------------------------------
27
+ # Schema loading
28
+ # -------------------------------------------------------------------------
29
+
30
+ # Load and validate the schema file exists
31
+ # Usage: load_schema [schema_path]
32
+ load_schema() {
33
+ local schema_path="${1:-$FRONTMATTER_SCHEMA_PATH}"
34
+ local repo_root
35
+ repo_root="$(get_repo_root)"
36
+ local full_path="$repo_root/$schema_path"
37
+
38
+ if [[ ! -f "$full_path" ]]; then
39
+ echo "ERROR: Schema file not found: $full_path" >&2
40
+ return 1
41
+ fi
42
+
43
+ # Validate YAML is parseable
44
+ if ! ruby -ryaml -e "YAML.load_file('$full_path')" 2>/dev/null; then
45
+ echo "ERROR: Invalid YAML in schema: $full_path" >&2
46
+ return 1
47
+ fi
48
+
49
+ echo "$full_path"
50
+ return 0
51
+ }
52
+
53
+ # Get list of collection names from schema
54
+ # Usage: get_collections [schema_path]
55
+ get_collections() {
56
+ local schema_path="${1:-$FRONTMATTER_SCHEMA_PATH}"
57
+ local repo_root
58
+ repo_root="$(get_repo_root)"
59
+ local full_path="$repo_root/$schema_path"
60
+
61
+ ruby -ryaml -e "
62
+ schema = YAML.load_file('$full_path')
63
+ (schema['collections'] || {}).keys.each { |k| puts k }
64
+ " 2>/dev/null
65
+ }
66
+
67
+ # Get required fields for a collection
68
+ # Usage: get_required_fields <collection> [schema_path]
69
+ get_required_fields() {
70
+ local collection="$1"
71
+ local schema_path="${2:-$FRONTMATTER_SCHEMA_PATH}"
72
+ local repo_root
73
+ repo_root="$(get_repo_root)"
74
+ local full_path="$repo_root/$schema_path"
75
+
76
+ ruby -ryaml -e "
77
+ schema = YAML.load_file('$full_path')
78
+ global_required = schema.dig('global', 'required_fields') || []
79
+ collection_required = schema.dig('collections', '$collection', 'required') || []
80
+ (global_required | collection_required).each { |f| puts f }
81
+ " 2>/dev/null
82
+ }
83
+
84
+ # Get optional fields for a collection
85
+ # Usage: get_optional_fields <collection> [schema_path]
86
+ get_optional_fields() {
87
+ local collection="$1"
88
+ local schema_path="${2:-$FRONTMATTER_SCHEMA_PATH}"
89
+ local repo_root
90
+ repo_root="$(get_repo_root)"
91
+ local full_path="$repo_root/$schema_path"
92
+
93
+ ruby -ryaml -e "
94
+ schema = YAML.load_file('$full_path')
95
+ fields = schema.dig('collections', '$collection', 'optional') || []
96
+ fields.each { |f| puts f }
97
+ " 2>/dev/null
98
+ }
99
+
100
+ # Get allowed layout values for a collection
101
+ # Usage: get_allowed_layouts <collection> [schema_path]
102
+ get_allowed_layouts() {
103
+ local collection="$1"
104
+ local schema_path="${2:-$FRONTMATTER_SCHEMA_PATH}"
105
+ local repo_root
106
+ repo_root="$(get_repo_root)"
107
+ local full_path="$repo_root/$schema_path"
108
+
109
+ ruby -ryaml -e "
110
+ schema = YAML.load_file('$full_path')
111
+ layouts = schema.dig('collections', '$collection', 'layout', 'allowed') || []
112
+ layouts.each { |l| puts l }
113
+ " 2>/dev/null
114
+ }
115
+
116
+ # Get path pattern for a collection
117
+ # Usage: get_path_pattern <collection> [schema_path]
118
+ get_path_pattern() {
119
+ local collection="$1"
120
+ local schema_path="${2:-$FRONTMATTER_SCHEMA_PATH}"
121
+ local repo_root
122
+ repo_root="$(get_repo_root)"
123
+ local full_path="$repo_root/$schema_path"
124
+
125
+ ruby -ryaml -e "
126
+ schema = YAML.load_file('$full_path')
127
+ puts schema.dig('collections', '$collection', 'path_pattern') || ''
128
+ " 2>/dev/null
129
+ }
130
+
131
+ # Get a global setting from schema
132
+ # Usage: get_global_setting <key> [schema_path]
133
+ get_global_setting() {
134
+ local key="$1"
135
+ local schema_path="${2:-$FRONTMATTER_SCHEMA_PATH}"
136
+ local repo_root
137
+ repo_root="$(get_repo_root)"
138
+ local full_path="$repo_root/$schema_path"
139
+
140
+ ruby -ryaml -e "
141
+ schema = YAML.load_file('$full_path')
142
+ val = schema.dig('global', '$key')
143
+ if val.is_a?(Hash)
144
+ val.each { |k, v| puts \"#{k}=#{v}\" }
145
+ elsif val.is_a?(Array)
146
+ val.each { |v| puts v }
147
+ else
148
+ puts val
149
+ end
150
+ " 2>/dev/null
151
+ }
152
+
153
+ # Get field type pattern from schema
154
+ # Usage: get_field_type_pattern <type_name> [schema_path]
155
+ get_field_type_pattern() {
156
+ local type_name="$1"
157
+ local schema_path="${2:-$FRONTMATTER_SCHEMA_PATH}"
158
+ local repo_root
159
+ repo_root="$(get_repo_root)"
160
+ local full_path="$repo_root/$schema_path"
161
+
162
+ ruby -ryaml -e "
163
+ schema = YAML.load_file('$full_path')
164
+ puts schema.dig('field_types', '$type_name', 'pattern') || ''
165
+ " 2>/dev/null
166
+ }
167
+
168
+ # Get canonical field mappings (deprecated → canonical)
169
+ # Usage: get_canonical_fields [schema_path]
170
+ get_canonical_fields() {
171
+ local schema_path="${1:-$FRONTMATTER_SCHEMA_PATH}"
172
+ local repo_root
173
+ repo_root="$(get_repo_root)"
174
+ local full_path="$repo_root/$schema_path"
175
+
176
+ ruby -ryaml -e "
177
+ schema = YAML.load_file('$full_path')
178
+ (schema.dig('global', 'canonical_fields') || {}).each { |k, v| puts \"#{k}=#{v}\" }
179
+ " 2>/dev/null
180
+ }
181
+
182
+ # -------------------------------------------------------------------------
183
+ # Content rules loading
184
+ # -------------------------------------------------------------------------
185
+
186
+ # Get template mappings from content rules
187
+ # Usage: get_template_mappings [rules_path]
188
+ get_template_mappings() {
189
+ local rules_path="${1:-$CONTENT_RULES_PATH}"
190
+ local repo_root
191
+ repo_root="$(get_repo_root)"
192
+ local full_path="$repo_root/$rules_path"
193
+
194
+ if [[ ! -f "$full_path" ]]; then
195
+ return 1
196
+ fi
197
+
198
+ ruby -ryaml -e "
199
+ rules = YAML.load_file('$full_path')
200
+ (rules['template_mappings'] || {}).each { |k, v| puts \"#{k}=#{v}\" }
201
+ " 2>/dev/null
202
+ }
203
+
204
+ # Get auto-fixable rule names
205
+ # Usage: get_auto_fixable_rules [rules_path]
206
+ get_auto_fixable_rules() {
207
+ local rules_path="${1:-$CONTENT_RULES_PATH}"
208
+ local repo_root
209
+ repo_root="$(get_repo_root)"
210
+ local full_path="$repo_root/$rules_path"
211
+
212
+ if [[ ! -f "$full_path" ]]; then
213
+ return 1
214
+ fi
215
+
216
+ ruby -ryaml -e "
217
+ rules = YAML.load_file('$full_path')
218
+ (rules['auto_fixable'] || []).each { |r| puts r }
219
+ " 2>/dev/null
220
+ }
221
+
222
+ # -------------------------------------------------------------------------
223
+ # Collection detection
224
+ # -------------------------------------------------------------------------
225
+
226
+ # Detect which collection a file belongs to based on path_pattern in schema
227
+ # Usage: detect_collection <filepath> [schema_path]
228
+ detect_collection() {
229
+ local filepath="$1"
230
+ local schema_path="${2:-$FRONTMATTER_SCHEMA_PATH}"
231
+ local repo_root
232
+ repo_root="$(get_repo_root)"
233
+ local full_path="$repo_root/$schema_path"
234
+
235
+ ruby -ryaml -e "
236
+ require 'pathname'
237
+ schema = YAML.load_file('$full_path')
238
+ filepath = '$filepath'
239
+ # Make path relative to repo root if absolute
240
+ filepath = Pathname.new(filepath).relative_path_from(Pathname.new('$repo_root')).to_s rescue filepath
241
+
242
+ matched = nil
243
+ (schema['collections'] || {}).each do |name, config|
244
+ pattern = config['path_pattern'] || ''
245
+ # Convert glob pattern to regex
246
+ regex_str = pattern.gsub('**/', '(.+/)?').gsub('**', '.*').gsub('*', '[^/]*')
247
+ if filepath.match?(Regexp.new('^' + regex_str + '$'))
248
+ matched = name
249
+ break
250
+ end
251
+ end
252
+ puts matched || 'unknown'
253
+ " 2>/dev/null
254
+ }
255
+
256
+ # -------------------------------------------------------------------------
257
+ # Frontmatter extraction
258
+ # -------------------------------------------------------------------------
259
+
260
+ # Extract frontmatter from a markdown file as YAML
261
+ # Usage: extract_frontmatter <filepath>
262
+ # Returns: YAML content between --- delimiters, or empty string
263
+ extract_frontmatter() {
264
+ local filepath="$1"
265
+
266
+ if [[ ! -f "$filepath" ]]; then
267
+ echo ""
268
+ return 1
269
+ fi
270
+
271
+ # Frontmatter must START on the first line with ---
272
+ # Otherwise mid-document horizontal rules would be misread as frontmatter.
273
+ local first_line
274
+ IFS= read -r first_line < "$filepath" || true
275
+ if [[ ! "$first_line" =~ ^---[[:space:]]*$ ]]; then
276
+ echo ""
277
+ return 1
278
+ fi
279
+
280
+ # Extract content between first pair of ---
281
+ awk '
282
+ /^---[[:space:]]*$/ {
283
+ if (count == 0) { count++; next }
284
+ if (count == 1) { exit }
285
+ }
286
+ count == 1 { print }
287
+ ' "$filepath"
288
+ }
289
+
290
+ # Get a specific frontmatter field value
291
+ # Usage: get_frontmatter_field <filepath> <field_name>
292
+ get_frontmatter_field() {
293
+ local filepath="$1"
294
+ local field="$2"
295
+ local fm
296
+
297
+ fm="$(extract_frontmatter "$filepath")"
298
+ if [[ -z "$fm" ]]; then
299
+ echo ""
300
+ return 1
301
+ fi
302
+
303
+ echo "$fm" | ruby -ryaml -rdate -e "
304
+ begin
305
+ data = YAML.safe_load(STDIN.read, permitted_classes: [Date, Time, Symbol], aliases: true) || {}
306
+ val = data['$field']
307
+ format = lambda do |v|
308
+ case v
309
+ when Time then v.utc.strftime('%Y-%m-%dT%H:%M:%S.') + format('%03d', v.usec / 1000) + 'Z'
310
+ when Date then v.strftime('%Y-%m-%d')
311
+ else v.to_s
312
+ end
313
+ end
314
+ if val.is_a?(Array)
315
+ val.each { |v| puts format.call(v) }
316
+ elsif val.nil?
317
+ # output nothing
318
+ else
319
+ puts format.call(val)
320
+ end
321
+ rescue => e
322
+ STDERR.puts \"YAML parse error: #{e.message}\"
323
+ exit 1
324
+ end
325
+ " 2>/dev/null
326
+ }
327
+
328
+ # List all frontmatter field names in a file
329
+ # Usage: list_frontmatter_fields <filepath>
330
+ list_frontmatter_fields() {
331
+ local filepath="$1"
332
+ local fm
333
+
334
+ fm="$(extract_frontmatter "$filepath")"
335
+ if [[ -z "$fm" ]]; then
336
+ return 1
337
+ fi
338
+
339
+ echo "$fm" | ruby -ryaml -rdate -e "
340
+ begin
341
+ data = YAML.safe_load(STDIN.read, permitted_classes: [Date, Time, Symbol], aliases: true) || {}
342
+ data.keys.each { |k| puts k }
343
+ rescue => e
344
+ STDERR.puts \"YAML parse error: #{e.message}\"
345
+ exit 1
346
+ end
347
+ " 2>/dev/null
348
+ }
349
+
350
+ # -------------------------------------------------------------------------
351
+ # Field validation
352
+ # -------------------------------------------------------------------------
353
+
354
+ # Validate a field value matches a pattern from schema
355
+ # Usage: validate_field_pattern <value> <pattern>
356
+ # Returns: 0 if valid, 1 if invalid
357
+ validate_field_pattern() {
358
+ local value="$1"
359
+ local pattern="$2"
360
+
361
+ if echo "$value" | grep -qE "$pattern"; then
362
+ return 0
363
+ fi
364
+ return 1
365
+ }
366
+
367
+ # Check if a value is a valid boolean
368
+ # Usage: validate_boolean <value>
369
+ validate_boolean() {
370
+ local value="$1"
371
+ case "$value" in
372
+ true|false) return 0 ;;
373
+ *) return 1 ;;
374
+ esac
375
+ }
376
+
377
+ # Check if a category follows the expected casing
378
+ # Usage: validate_category_casing <value> <casing_rule>
379
+ validate_category_casing() {
380
+ local value="$1"
381
+ local casing="${2:-title}"
382
+
383
+ case "$casing" in
384
+ title)
385
+ # Title case: first letter of each whitespace-separated word uppercase, rest lowercase
386
+ local expected="" word first rest
387
+ for word in $value; do
388
+ first="${word:0:1}"
389
+ rest="${word:1}"
390
+ first="$(printf '%s' "$first" | tr '[:lower:]' '[:upper:]')"
391
+ rest="$(printf '%s' "$rest" | tr '[:upper:]' '[:lower:]')"
392
+ expected+="${expected:+ }${first}${rest}"
393
+ done
394
+ [[ "$value" == "$expected" ]]
395
+ ;;
396
+ lower)
397
+ [[ "$value" == "$(echo "$value" | tr '[:upper:]' '[:lower:]')" ]]
398
+ ;;
399
+ upper)
400
+ [[ "$value" == "$(echo "$value" | tr '[:lower:]' '[:upper:]')" ]]
401
+ ;;
402
+ *)
403
+ return 0
404
+ ;;
405
+ esac
406
+ }
407
+
408
+ # -------------------------------------------------------------------------
409
+ # Bulk frontmatter parsing (performance-optimized)
410
+ # -------------------------------------------------------------------------
411
+
412
+ # Parse a file's frontmatter and emit ALL needed validation data in a
413
+ # single Ruby invocation. Output is line-prefixed for easy bash parsing:
414
+ #
415
+ # FIELD:<name> (one line per top-level key present)
416
+ # LAYOUT:<value>
417
+ # DATE:<iso8601>
418
+ # LASTMOD:<iso8601>
419
+ # DRAFT:<value>
420
+ # CATEGORY:<value> (one line per category)
421
+ #
422
+ # Special outputs:
423
+ # __NO_FRONTMATTER__ file lacks a leading frontmatter block
424
+ # __PARSE_ERROR__:<msg> YAML parse failure
425
+ #
426
+ # Usage: parse_file_frontmatter_all <filepath>
427
+ parse_file_frontmatter_all() {
428
+ local filepath="$1"
429
+
430
+ # Cheap pre-check: must start with --- on line 1
431
+ local first_line
432
+ IFS= read -r first_line < "$filepath" || true
433
+ if [[ ! "$first_line" =~ ^---[[:space:]]*$ ]]; then
434
+ echo "__NO_FRONTMATTER__"
435
+ return 0
436
+ fi
437
+
438
+ ruby -ryaml -rdate -e '
439
+ path = ARGV[0]
440
+ # Extract frontmatter block (between first pair of --- on their own lines)
441
+ content = File.read(path)
442
+ unless content =~ /\A---\s*\n(.*?)\n---\s*$/m
443
+ puts "__NO_FRONTMATTER__"
444
+ exit 0
445
+ end
446
+ fm_text = $1
447
+
448
+ begin
449
+ data = YAML.safe_load(fm_text, permitted_classes: [Date, Time, Symbol], aliases: true) || {}
450
+ rescue => e
451
+ puts "__PARSE_ERROR__:#{e.message}"
452
+ exit 0
453
+ end
454
+
455
+ unless data.is_a?(Hash)
456
+ puts "__PARSE_ERROR__:frontmatter is not a mapping"
457
+ exit 0
458
+ end
459
+
460
+ fmt = lambda do |v|
461
+ case v
462
+ when Time then v.utc.strftime("%Y-%m-%dT%H:%M:%S.") + format("%03d", v.usec / 1000) + "Z"
463
+ when Date then v.strftime("%Y-%m-%d")
464
+ else v.to_s
465
+ end
466
+ end
467
+
468
+ data.keys.each { |k| puts "FIELD:#{k}" }
469
+
470
+ ["layout", "date", "lastmod", "draft"].each do |key|
471
+ v = data[key]
472
+ next if v.nil?
473
+ tag = key.upcase
474
+ if v.is_a?(Array)
475
+ v.each { |item| puts "#{tag}:#{fmt.call(item)}" }
476
+ else
477
+ puts "#{tag}:#{fmt.call(v)}"
478
+ end
479
+ end
480
+
481
+ cats = data["categories"]
482
+ if cats.is_a?(Array)
483
+ cats.each { |c| puts "CATEGORY:#{fmt.call(c)}" }
484
+ elsif !cats.nil?
485
+ puts "CATEGORY:#{fmt.call(cats)}"
486
+ end
487
+ ' "$filepath" 2>/dev/null || echo "__PARSE_ERROR__:ruby failed"
488
+ }
489
+
490
+ # -------------------------------------------------------------------------
491
+ # Fix transformations
492
+ # -------------------------------------------------------------------------
493
+
494
+ # Normalize a date to ISO 8601 format
495
+ # Usage: fix_date_format <date_string>
496
+ fix_date_format() {
497
+ local date_str="$1"
498
+
499
+ # Already ISO 8601 with time
500
+ if echo "$date_str" | grep -qE '^[0-9]{4}-[0-9]{2}-[0-9]{2}T'; then
501
+ echo "$date_str"
502
+ return 0
503
+ fi
504
+
505
+ # Simple date: append time
506
+ if echo "$date_str" | grep -qE '^[0-9]{4}-[0-9]{2}-[0-9]{2}$'; then
507
+ echo "${date_str}T00:00:00.000Z"
508
+ return 0
509
+ fi
510
+
511
+ # Return as-is if unrecognized
512
+ echo "$date_str"
513
+ return 1
514
+ }
515
+
516
+ # Convert string draft values to boolean
517
+ # Usage: fix_draft_to_boolean <value>
518
+ fix_draft_to_boolean() {
519
+ local value="$1"
520
+
521
+ case "$value" in
522
+ true|True|TRUE|draft|Draft|"in progress"|"In Progress")
523
+ echo "true"
524
+ ;;
525
+ false|False|FALSE|published|Published)
526
+ echo "false"
527
+ ;;
528
+ *)
529
+ echo "$value"
530
+ return 1
531
+ ;;
532
+ esac
533
+ }
534
+
535
+ # Export functions for use by other scripts
536
+ export -f load_schema get_collections get_required_fields get_optional_fields
537
+ export -f get_allowed_layouts get_path_pattern get_global_setting
538
+ export -f get_field_type_pattern get_canonical_fields
539
+ export -f get_template_mappings get_auto_fixable_rules
540
+ export -f detect_collection extract_frontmatter get_frontmatter_field
541
+ export -f list_frontmatter_fields validate_field_pattern validate_boolean
542
+ export -f validate_category_casing fix_date_format fix_draft_to_boolean
543
+ export -f parse_file_frontmatter_all