jekyll-theme-resume 1.3.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 (123) hide show
  1. checksums.yaml +7 -0
  2. data/403.html +6 -0
  3. data/404.html +6 -0
  4. data/500.html +6 -0
  5. data/CHANGELOG.md +498 -0
  6. data/CODE_OF_CONDUCT.md +92 -0
  7. data/LICENSE.txt +22 -0
  8. data/README.md +184 -0
  9. data/SECURITY.md +30 -0
  10. data/_config.sample.yml +329 -0
  11. data/_data/locales/ar.yml +85 -0
  12. data/_data/locales/de.yml +86 -0
  13. data/_data/locales/en.yml +84 -0
  14. data/_data/locales/es.yml +86 -0
  15. data/_data/locales/fr.yml +86 -0
  16. data/_data/locales/ur.yml +86 -0
  17. data/_data/social_networks.yml +83 -0
  18. data/_includes/analytics-body.html +12 -0
  19. data/_includes/analytics-head.html +36 -0
  20. data/_includes/avatar.html +84 -0
  21. data/_includes/dark-mode-toggle.html +202 -0
  22. data/_includes/data-loader.html +42 -0
  23. data/_includes/date-formatter.html +63 -0
  24. data/_includes/grouped-item-list.html +53 -0
  25. data/_includes/hreflang.html +58 -0
  26. data/_includes/language-switcher.html +72 -0
  27. data/_includes/print-social-links.html +23 -0
  28. data/_includes/resume-section.html +547 -0
  29. data/_includes/safe-url.html +21 -0
  30. data/_includes/shared-head.html +51 -0
  31. data/_includes/social-links.html +54 -0
  32. data/_includes/vendors/svg-icons/ATTRIBUTION.md +34 -0
  33. data/_includes/vendors/svg-icons/dev.svg +3 -0
  34. data/_includes/vendors/svg-icons/dribbble-symbol.svg +3 -0
  35. data/_includes/vendors/svg-icons/envelope.svg +4 -0
  36. data/_includes/vendors/svg-icons/facebook.svg +3 -0
  37. data/_includes/vendors/svg-icons/flickr.svg +4 -0
  38. data/_includes/vendors/svg-icons/github.svg +3 -0
  39. data/_includes/vendors/svg-icons/globe-1.svg +3 -0
  40. data/_includes/vendors/svg-icons/instagram.svg +4 -0
  41. data/_includes/vendors/svg-icons/linkedin.svg +3 -0
  42. data/_includes/vendors/svg-icons/medium.svg +3 -0
  43. data/_includes/vendors/svg-icons/phone.svg +13 -0
  44. data/_includes/vendors/svg-icons/pinterest.svg +3 -0
  45. data/_includes/vendors/svg-icons/postcard.svg +14 -0
  46. data/_includes/vendors/svg-icons/telegram.svg +3 -0
  47. data/_includes/vendors/svg-icons/whatsapp.svg +3 -0
  48. data/_includes/vendors/svg-icons/x.svg +3 -0
  49. data/_includes/vendors/svg-icons/youtube.svg +3 -0
  50. data/_layouts/default.html +65 -0
  51. data/_layouts/error.html +138 -0
  52. data/_layouts/profile.html +123 -0
  53. data/_layouts/resume.html +238 -0
  54. data/_plugins/error_pages_generator.rb +71 -0
  55. data/_plugins/json_resume_generator.rb +79 -0
  56. data/_plugins/resume_pages_generator.rb +71 -0
  57. data/_plugins/resume_validator.rb +40 -0
  58. data/_sass/_all-pages.scss +335 -0
  59. data/_sass/_base.scss +130 -0
  60. data/_sass/_dark-mode.scss +387 -0
  61. data/_sass/_layout.scss +116 -0
  62. data/_sass/_mixins.scss +136 -0
  63. data/_sass/_normalize.scss +379 -0
  64. data/_sass/_profile-page.scss +60 -0
  65. data/_sass/_resume-ltr.scss +436 -0
  66. data/_sass/_resume-rtl.scss +91 -0
  67. data/_sass/_variables.scss +45 -0
  68. data/assets/css/cv-ltr.scss +31 -0
  69. data/assets/css/cv-rtl.scss +36 -0
  70. data/assets/css/main.scss +8 -0
  71. data/assets/css/profile.scss +9 -0
  72. data/assets/favicon/resume/about.txt +6 -0
  73. data/assets/favicon/resume/android-chrome-192x192.png +0 -0
  74. data/assets/favicon/resume/android-chrome-512x512.png +0 -0
  75. data/assets/favicon/resume/apple-touch-icon.png +0 -0
  76. data/assets/favicon/resume/favicon-16x16.png +0 -0
  77. data/assets/favicon/resume/favicon-32x32.png +0 -0
  78. data/assets/favicon/resume/favicon.ico +0 -0
  79. data/assets/favicon/resume/site.webmanifest +1 -0
  80. data/bin/validate-resume +89 -0
  81. data/bin/verify +27 -0
  82. data/docs/README.md +89 -0
  83. data/docs/explanation/accessibility-decisions.md +27 -0
  84. data/docs/explanation/architecture.md +44 -0
  85. data/docs/explanation/dark-mode-approach.md +25 -0
  86. data/docs/explanation/data-driven-model.md +25 -0
  87. data/docs/explanation/multilingual-and-rtl-design.md +40 -0
  88. data/docs/how-to/add-a-language.md +57 -0
  89. data/docs/how-to/add-a-section.md +35 -0
  90. data/docs/how-to/add-a-social-platform.md +32 -0
  91. data/docs/how-to/add-a-test.md +34 -0
  92. data/docs/how-to/create-a-custom-layout.md +64 -0
  93. data/docs/how-to/enable-dark-mode.md +43 -0
  94. data/docs/how-to/link-translations-with-hreflang.md +25 -0
  95. data/docs/how-to/migrate-v0.9-to-v1.0.md +46 -0
  96. data/docs/how-to/override-locale-strings.md +51 -0
  97. data/docs/how-to/override-sass-partials.md +26 -0
  98. data/docs/how-to/proof-built-html.md +22 -0
  99. data/docs/how-to/publish-json-resume.md +38 -0
  100. data/docs/how-to/show-language-proficiency-in-header.md +13 -0
  101. data/docs/how-to/switch-resume-versions.md +42 -0
  102. data/docs/how-to/troubleshoot-builds.md +39 -0
  103. data/docs/how-to/validate-in-ci.md +38 -0
  104. data/docs/how-to/verify-accessibility.md +28 -0
  105. data/docs/reference/accessibility-coverage.md +39 -0
  106. data/docs/reference/config.md +378 -0
  107. data/docs/reference/data-schemas.md +529 -0
  108. data/docs/reference/glossary.md +39 -0
  109. data/docs/reference/includes.md +197 -0
  110. data/docs/reference/json-resume-fields.md +148 -0
  111. data/docs/reference/layouts.md +144 -0
  112. data/docs/reference/locale-keys.md +76 -0
  113. data/docs/reference/repository-map.md +83 -0
  114. data/docs/reference/sass-tokens.md +279 -0
  115. data/docs/reference/testing-suites.md +62 -0
  116. data/docs/reference/validator-cli.md +203 -0
  117. data/docs/tutorials/getting-started.md +173 -0
  118. data/lib/jekyll-theme-resume/json_resume_exporter.rb +334 -0
  119. data/lib/jekyll-theme-resume/resume_validator.rb +853 -0
  120. data/lib/jekyll-theme-resume/schemas/LICENSE.md +21 -0
  121. data/lib/jekyll-theme-resume/schemas/json_resume_v1.0.0.json +500 -0
  122. data/lib/jekyll-theme-resume.rb +21 -0
  123. metadata +371 -0
@@ -0,0 +1,853 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "uri"
5
+ require "date"
6
+
7
+ module JekyllThemeResume
8
+ # Validates resume YAML data against expected schemas, checks section-file parity
9
+ # across every configured language, and provides colorized diagnostics.
10
+ #
11
+ # Languages come from the site's `languages:` config (each resolved through its
12
+ # `data_path`), falling back to a directory scan when no config is found. Locale
13
+ # data (present values, display names) comes from the theme's
14
+ # `_data/locales/<lang>.yml` with the site's `<data_dir>/locales/<lang>.yml`
15
+ # deep-merged over it, the same way Jekyll layers theme and site data.
16
+ class ResumeValidator
17
+ COLORS = {
18
+ red: "\e[31m",
19
+ green: "\e[32m",
20
+ yellow: "\e[33m",
21
+ blue: "\e[34m",
22
+ cyan: "\e[36m",
23
+ bold: "\e[1m",
24
+ reset: "\e[0m"
25
+ }.freeze
26
+
27
+ THEME_LOCALES_DIR = File.expand_path("../../_data/locales", __dir__)
28
+
29
+ EXCLUDED_LOCALE_DIRS = %w[
30
+ locales sample samples archive archives rawdata data assets images img css js
31
+ ].freeze
32
+
33
+ # The one allowed shape for a language key. Keys are joined into file paths and globs, so
34
+ # anything else (`../x`, `a/b`, `*`) is rejected before it reaches the filesystem.
35
+ LANG_KEY_REGEX = /\A[a-zA-Z0-9_-]+\z/
36
+
37
+ # Tracking IDs are emitted into inline scripts and URLs; only these shapes are accepted.
38
+ # gtag covers G-, AW- and similar measurement IDs.
39
+ ANALYTICS_ID_REGEXES = { "gtm" => /\AGTM-[A-Z0-9]+\z/, "gtag" => /\A[A-Za-z0-9-]+\z/ }.freeze
40
+
41
+ # Allowed URL schemes per config setting; _includes/safe-url.html applies the same rule.
42
+ # Other social_links keys use SOCIAL_LINK_SCHEMES.
43
+ CONFIG_URL_SCHEMES = {
44
+ "avatar_url" => %w[http https], "avatar_link" => %w[http https mailto tel]
45
+ }.freeze
46
+ SOCIAL_LINK_SCHEMES = %w[http https].freeze
47
+ SOCIAL_EMAIL_SCHEMES = %w[mailto].freeze
48
+
49
+ LOCALE_DIR_REGEX = /\A[a-z]{2,3}(?:[-_][a-zA-Z0-9]{2,4})?\z/i
50
+
51
+ # Only these three ISO shapes are accepted by validate_date; parse_date_safely's
52
+ # Date.parse fallback for other shapes is deliberately not reachable from there.
53
+ STRICT_ISO_DATE_REGEX = /\A\d{4}(-\d{2}(-\d{2})?)?\z/
54
+
55
+ attr_reader :data_dir, :errors, :warnings, :info, :primary_locale
56
+
57
+ # config: an already-parsed Jekyll site config Hash (the Jekyll plugin passes
58
+ # site.config). When nil, the config is looked up on disk (config_path first).
59
+ def initialize(data_dir = "_data", config_path: nil, config: nil, primary_locale: "en")
60
+ @data_dir = data_dir.to_s.chomp("/")
61
+ @config_path = config_path
62
+ @config = config
63
+ @primary_locale = (primary_locale || "en").to_s
64
+ @errors = []
65
+ @warnings = []
66
+ @info = []
67
+ @use_color = $stdout.tty? && ENV["NO_COLOR"].nil?
68
+ @locales = {}
69
+ @lang_dirs = {}
70
+ end
71
+
72
+ # Runs validation across configured languages.
73
+ # Returns 0 on success, 1 on error (or on warnings if fail_on_warnings is true).
74
+ def validate(languages: nil, all_locales: false, primary_locale: nil, verbose: false, quiet: false, fail_on_warnings: false)
75
+ @primary_locale = primary_locale.to_s if primary_locale
76
+ @verbose = verbose
77
+ @quiet = quiet
78
+ @errors.clear
79
+ @warnings.clear
80
+ @info.clear
81
+ @locales = {}
82
+
83
+ log_info("🔍 Validating resume data in '#{@data_dir}'...")
84
+
85
+ unless Dir.exist?(@data_dir)
86
+ add_error(@data_dir, "Data directory '#{@data_dir}' does not exist.")
87
+ report_results
88
+ return 1
89
+ end
90
+
91
+ config_languages = load_language_config
92
+ explicit = valid_language_keys(Array(languages).map(&:to_s))
93
+ declared = explicit.empty? ? config_languages : explicit
94
+
95
+ target_languages = resolve_target_languages(explicit, config_languages, all_locales)
96
+ target_languages -= report_misconfigured_languages(declared)
97
+ log_info("🌐 Languages: #{target_languages.map { |l| "#{l} (#{lang_dir(l)})" }.join(', ')}")
98
+
99
+ add_warning("Discovery", "No language directories found in '#{@data_dir}'.") if target_languages.empty?
100
+
101
+ validate_locale_parity(target_languages)
102
+ validate_language_parity(target_languages)
103
+ target_languages.each { |lang| validate_language_files(lang) }
104
+
105
+ report_results unless @quiet && @errors.empty? && @warnings.empty?
106
+
107
+ if @errors.any? || (fail_on_warnings && @warnings.any?)
108
+ 1
109
+ else
110
+ 0
111
+ end
112
+ end
113
+
114
+ # Auto-discovers language directories in data_dir matching BCP-47 / ISO patterns
115
+ # while filtering out non-locale directories.
116
+ def discover_languages
117
+ return [] unless Dir.exist?(@data_dir)
118
+
119
+ subdirs = Dir.children(@data_dir).select do |entry|
120
+ full = File.join(@data_dir, entry)
121
+ File.directory?(full) &&
122
+ entry =~ LOCALE_DIR_REGEX &&
123
+ !EXCLUDED_LOCALE_DIRS.include?(entry.downcase) &&
124
+ Dir.glob(File.join(full, "*.{yml,yaml}")).any?
125
+ end
126
+
127
+ if subdirs.include?(@primary_locale)
128
+ [@primary_locale] + (subdirs - [@primary_locale]).sort
129
+ else
130
+ subdirs.sort
131
+ end
132
+ end
133
+
134
+ # Effective locale for a language: the theme's _data/locales/<lang>.yml with the
135
+ # site's <data_dir>/locales/<lang>.yml deep-merged over it. A site-only locale is
136
+ # used as-is. Returns nil when neither file exists. Cached per validate() run.
137
+ def locale_for(lang)
138
+ lang = lang.to_s.strip.downcase
139
+ return @locales[lang] if @locales.key?(lang)
140
+
141
+ theme = read_locale_file(THEME_LOCALES_DIR, lang)
142
+ site = read_locale_file(File.join(@data_dir, "locales"), lang)
143
+ @locales[lang] = theme && site ? deep_merge(theme, site) : (site || theme)
144
+ end
145
+
146
+ # Returns the "Present" date values the theme accepts for a language: the merged
147
+ # locale's `present_values` plus its `ui.present` label. Mirrors
148
+ # _includes/date-formatter.html, including its fallback to the default language's
149
+ # locale when the language has none. A nil/empty lang returns every locale's values.
150
+ def present_aliases_for(lang = nil)
151
+ langs = lang.to_s.strip.empty? ? known_locale_languages : [lang]
152
+ langs.flat_map do |l|
153
+ locale = locale_for(l) || locale_for(@default_lang || @primary_locale)
154
+ locale_present_values(locale)
155
+ end.uniq
156
+ end
157
+
158
+ # Determines whether a date value represents a "Present" or ongoing role.
159
+ def present_date?(date_val, lang: nil)
160
+ return false if date_val.nil? || date_val.is_a?(Date) || date_val.is_a?(Time)
161
+
162
+ str = date_val.to_s.strip
163
+ return true if str.empty?
164
+
165
+ aliases = present_aliases_for(lang)
166
+ str_down = str.downcase
167
+
168
+ aliases.any? { |a| a.to_s.strip.downcase == str_down || a.to_s.strip == str }
169
+ end
170
+
171
+ private
172
+
173
+ def color(code_key, text)
174
+ return text unless @use_color
175
+
176
+ "#{COLORS[code_key]}#{text}#{COLORS[:reset]}"
177
+ end
178
+
179
+ def log_info(msg)
180
+ puts msg unless @quiet
181
+ end
182
+
183
+ # Explicit --languages wins; otherwise the config's `languages:` block; otherwise a
184
+ # directory scan. --all-locales unions the directory scan on top of either list.
185
+ def resolve_target_languages(explicit, config_languages, all_locales)
186
+ base = if explicit.any? then explicit
187
+ elsif config_languages.any? then config_languages
188
+ else discover_languages
189
+ end
190
+ all_locales ? (base + discover_languages).uniq : base
191
+ end
192
+
193
+ # A declared language (from --languages or the `languages:` config) with no data
194
+ # folder or no locale is a misconfiguration, not a parity gap: report it as an error
195
+ # and return the languages without a folder so parity does not double-report them.
196
+ def report_misconfigured_languages(declared)
197
+ declared.each do |lang|
198
+ next if locale_for(lang)
199
+
200
+ add_error(lang, "No locale found for '#{lang}': expected #{File.join(THEME_LOCALES_DIR, "#{lang}.yml")} " \
201
+ "or #{File.join(@data_dir, 'locales', "#{lang}.yml")}.")
202
+ end
203
+
204
+ declared.reject { |lang| Dir.exist?(lang_dir(lang)) }.each do |lang|
205
+ add_error(lang, "Data directory '#{lang_dir(lang)}' for language '#{lang}' does not exist.")
206
+ end
207
+ end
208
+
209
+ # Data folder for a language: its configured data_path (dot-separated, like
210
+ # _includes/data-loader.html) or, for undeclared languages, <data_dir>/<lang>.
211
+ def lang_dir(lang)
212
+ @lang_dirs[lang] || File.join(@data_dir, lang)
213
+ end
214
+
215
+ # Loads the site config and records each declared language's data folder in
216
+ # @lang_dirs. Returns the declared language codes ([] when there is no config).
217
+ def load_language_config
218
+ @lang_dirs = {}
219
+ @default_lang = nil
220
+ config = @config || load_site_config
221
+ return [] unless config.is_a?(Hash)
222
+
223
+ @default_lang = config["default_lang"]&.to_s
224
+ validate_analytics_ids(config["analytics"])
225
+ validate_config_urls(config)
226
+ return [] unless config["languages"].is_a?(Hash)
227
+
228
+ # A language without data_path is reported once here and left out of the run.
229
+ config["languages"].filter_map do |lang, lang_cfg|
230
+ lang = lang.to_s
231
+ next if valid_language_keys([lang]).empty?
232
+
233
+ data_path = lang_cfg["data_path"] if lang_cfg.is_a?(Hash)
234
+ if data_path.nil?
235
+ add_error("Config", "languages.#{lang} has no 'data_path' (use \"\" for the data root).")
236
+ next
237
+ end
238
+
239
+ @lang_dirs[lang] = File.join(@data_dir, *data_path.to_s.split("."))
240
+ lang
241
+ end
242
+ end
243
+
244
+ # A value with ":" must start with an allowed scheme; one without is a relative path (or a
245
+ # bare email address) and is allowed. Mirrors _includes/safe-url.html.
246
+ def safe_url?(value, schemes)
247
+ text = value.to_s.strip
248
+ !text.include?(":") || schemes.include?(text.split(":").first.downcase)
249
+ end
250
+
251
+ def validate_config_urls(config)
252
+ checks = CONFIG_URL_SCHEMES.map { |key, schemes| [key, config[key], schemes] }
253
+ social = config["social_links"].is_a?(Hash) ? config["social_links"] : {}
254
+ social.each do |key, value|
255
+ checks << ["social_links.#{key}", value, key.to_s == "email" ? SOCIAL_EMAIL_SCHEMES : SOCIAL_LINK_SCHEMES]
256
+ end
257
+ checks.each do |name, value, schemes|
258
+ next if value.nil? || value == false || safe_url?(value, schemes)
259
+
260
+ add_error("Config", "#{name} #{value.to_s.inspect} must be a relative path or use #{schemes.join('/')}.")
261
+ end
262
+ end
263
+
264
+ def validate_analytics_ids(analytics)
265
+ return unless analytics.is_a?(Hash)
266
+
267
+ ANALYTICS_ID_REGEXES.each do |key, regex|
268
+ value = analytics[key]
269
+ next if value.nil? || value.to_s.empty? || regex.match?(value.to_s)
270
+
271
+ add_error("Config", "analytics.#{key} #{value.to_s.inspect} is not a valid ID (expected #{regex.source}).")
272
+ end
273
+ end
274
+
275
+ # Reports every key that is not a plain language code and returns the rest.
276
+ def valid_language_keys(keys)
277
+ keys.select do |key|
278
+ next true if LANG_KEY_REGEX.match?(key)
279
+
280
+ add_error("Config", "Invalid language key #{key.inspect}: use only letters, digits, '-' and '_'.")
281
+ false
282
+ end
283
+ end
284
+
285
+ # First Jekyll config found: explicit config_path, then data-dir neighbours.
286
+ # An explicit config_path that is missing, or any config that fails to parse, is an error.
287
+ def load_site_config
288
+ if @config_path && !File.file?(@config_path)
289
+ add_error("Config", "Config file '#{@config_path}' does not exist.")
290
+ return nil
291
+ end
292
+
293
+ cfg_file = [
294
+ @config_path,
295
+ File.join(@data_dir, "_config.yml"),
296
+ File.join(@data_dir, "_config.sample.yml"),
297
+ File.join(@data_dir, "..", "_config.yml"),
298
+ File.join(@data_dir, "..", "_config.sample.yml")
299
+ ].compact.find { |f| File.file?(f) }
300
+ return nil unless cfg_file
301
+
302
+ log_info("⚙️ Using config '#{cfg_file}'")
303
+ load_yaml_file(cfg_file)
304
+ rescue Psych::SyntaxError => e
305
+ add_error(cfg_file, "YAML Syntax Error: line #{e.line}, col #{e.column}: #{e.problem}")
306
+ nil
307
+ rescue Psych::Exception => e
308
+ add_error(cfg_file, "Unsupported YAML (aliases and custom classes are not allowed): #{e.message}")
309
+ nil
310
+ end
311
+
312
+ def read_locale_file(dir, lang)
313
+ path = Dir.glob(File.join(dir, "#{lang}.{yml,yaml}")).first
314
+ return nil unless path
315
+
316
+ data = load_yaml_file(path)
317
+ return data if data.is_a?(Hash)
318
+
319
+ add_error(path, "Locale file must be a Hash/dictionary, got #{data.class}.")
320
+ nil
321
+ rescue Psych::SyntaxError => e
322
+ add_error(path, "YAML Syntax Error: line #{e.line}, col #{e.column}: #{e.problem}")
323
+ nil
324
+ rescue Psych::Exception => e
325
+ add_error(path, "Unsupported YAML (aliases and custom classes are not allowed): #{e.message}")
326
+ nil
327
+ end
328
+
329
+ # Hashes merge key by key; everything else (arrays included) is replaced whole.
330
+ def deep_merge(base, override)
331
+ base.merge(override) do |_key, old, new|
332
+ old.is_a?(Hash) && new.is_a?(Hash) ? deep_merge(old, new) : new
333
+ end
334
+ end
335
+
336
+ def known_locale_languages
337
+ [THEME_LOCALES_DIR, File.join(@data_dir, "locales")]
338
+ .flat_map { |dir| Dir.glob(File.join(dir, "*.{yml,yaml}")) }
339
+ .map { |f| File.basename(f, ".*").downcase }
340
+ .uniq
341
+ end
342
+
343
+ def locale_present_values(locale)
344
+ return [] unless locale
345
+
346
+ values = Array(locale["present_values"]).map(&:to_s)
347
+ values << locale["ui"]["present"].to_s if locale["ui"].is_a?(Hash) && locale["ui"]["present"]
348
+ values
349
+ end
350
+
351
+ # Warns when a language's merged locale (theme + site override) is missing keys the
352
+ # reference locale has. Runs on the merged locale, never the raw site file, so a
353
+ # one-line site override warns on nothing; only a genuinely incomplete locale does.
354
+ def validate_locale_parity(languages)
355
+ reference = [@primary_locale, @default_lang, *languages].compact.find { |l| locale_for(l) }
356
+ return unless reference
357
+
358
+ reference_keys = flatten_keys(locale_for(reference))
359
+
360
+ languages.each do |lang|
361
+ next if lang == reference
362
+
363
+ locale = locale_for(lang)
364
+ next unless locale
365
+
366
+ missing = (reference_keys - flatten_keys(locale)).to_a.sort
367
+ next if missing.empty?
368
+
369
+ add_warning("Locale #{lang}", "Missing key(s) vs. '#{reference}' locale: #{missing.join(', ')}")
370
+ end
371
+ end
372
+
373
+ # Flattens a locale Hash into dotted key paths (e.g. "ui.section_titles.experience").
374
+ # Only Hash values are descended into; arrays and scalars are leaves.
375
+ def flatten_keys(hash, prefix = "")
376
+ hash.each_with_object(Set.new) do |(key, val), keys|
377
+ path = prefix.empty? ? key.to_s : "#{prefix}.#{key}"
378
+ val.is_a?(Hash) ? keys.merge(flatten_keys(val, path)) : (keys << path)
379
+ end
380
+ end
381
+
382
+ # Checks parity of section files across all languages using a hub-and-spoke model.
383
+ def validate_language_parity(languages)
384
+ return unless languages.size > 1
385
+
386
+ dir_files = collect_language_files(languages)
387
+
388
+ # Designate primary hub locale
389
+ primary = if dir_files.key?(@primary_locale)
390
+ @primary_locale
391
+ else
392
+ languages.find { |l| dir_files.key?(l) } || languages.first
393
+ end
394
+
395
+ languages.reject { |l| l == primary }.each do |secondary|
396
+ next unless dir_files.key?(secondary)
397
+
398
+ report_parity_gaps(primary, dir_files[primary] || [], secondary, dir_files[secondary])
399
+ end
400
+ end
401
+
402
+ # Maps each existing language directory to its section file names.
403
+ def collect_language_files(languages)
404
+ languages.each_with_object({}) do |lang, dir_files|
405
+ lang_path = lang_dir(lang)
406
+ unless Dir.exist?(lang_path)
407
+ add_warning("Parity", "Language directory '#{lang_path}' does not exist.")
408
+ next
409
+ end
410
+
411
+ names = Dir.glob(File.join(lang_path, "*.{yml,yaml}")).map { |f| File.basename(f) }
412
+ dir_files[lang] = names.sort
413
+ end
414
+ end
415
+
416
+ def report_parity_gaps(primary, primary_files, secondary, sec_files)
417
+ primary_dir = lang_dir(primary)
418
+ secondary_dir = lang_dir(secondary)
419
+
420
+ (primary_files - sec_files).each do |f|
421
+ add_warning("Parity", "Missing #{language_display_name(secondary)} counterpart: " \
422
+ "#{File.join(secondary_dir, f)} (exists in #{primary_dir}/)")
423
+ end
424
+
425
+ (sec_files - primary_files).each do |f|
426
+ add_warning("Parity", "Missing #{language_display_name(primary)} counterpart: " \
427
+ "#{File.join(primary_dir, f)} (exists in #{secondary_dir}/)")
428
+ end
429
+ end
430
+
431
+ # Native display name from the merged locale's ui.language_name, else the raw code.
432
+ def language_display_name(lang)
433
+ ui = locale_for(lang)&.fetch("ui", nil)
434
+ (ui["language_name"] if ui.is_a?(Hash)) || lang.to_s
435
+ end
436
+
437
+ def validate_language_files(lang)
438
+ dir = lang_dir(lang)
439
+ return unless Dir.exist?(dir)
440
+
441
+ files = Dir.glob(File.join(dir, "*.{yml,yaml}"))
442
+ add_warning(lang, "No YAML data files found in '#{dir}'.") if files.empty?
443
+
444
+ files.each do |file_path|
445
+ filename = File.basename(file_path)
446
+ section_name = File.basename(file_path, ".*")
447
+
448
+ begin
449
+ data = load_yaml_file(file_path)
450
+ validate_section(section_name, data, lang, file_path)
451
+ rescue Psych::SyntaxError => e
452
+ add_error("#{lang}/#{filename}", "YAML Syntax Error: line #{e.line}, col #{e.column}: #{e.problem}")
453
+ rescue Psych::Exception => e
454
+ add_error("#{lang}/#{filename}", "Unsupported YAML (aliases and custom classes are not allowed): #{e.message}")
455
+ rescue StandardError => e
456
+ add_error("#{lang}/#{filename}", "Error loading YAML: #{e.message}")
457
+ end
458
+ end
459
+ end
460
+
461
+ def load_yaml_file(file_path)
462
+ YAML.load_file(file_path, permitted_classes: [Date, Time])
463
+ end
464
+
465
+ def validate_section(section_name, data, lang, file_path)
466
+ filename = File.basename(file_path)
467
+ context = "#{lang}/#{filename}"
468
+
469
+ if data.nil?
470
+ add_warning(context, "File is empty or contains only comments.")
471
+ return
472
+ end
473
+
474
+ if section_name == "header"
475
+ validate_header(data, context)
476
+ return
477
+ end
478
+
479
+ unless data.is_a?(Array)
480
+ add_error(context, "Expected a list/array of items, but found #{data.class}.")
481
+ return
482
+ end
483
+
484
+ data.each_with_index do |entry, idx|
485
+ item_context = "#{context} [Item ##{idx + 1}]"
486
+ validate_item_entry(section_name, entry, item_context, lang: lang)
487
+ end
488
+ end
489
+
490
+ # Flat section-name dispatcher: one branch per section is clearer than a lookup table here.
491
+ def validate_item_entry(section_name, entry, item_context, lang: nil) # rubocop:disable Metrics/CyclomaticComplexity
492
+ unless entry.is_a?(Hash)
493
+ add_error(item_context, "Item must be a Hash/dictionary, got #{entry.class}.")
494
+ return
495
+ end
496
+
497
+ # 1. Active flag check (applies across all standard sections except interests)
498
+ validate_active_flag(entry, item_context) unless section_name == "interests"
499
+
500
+ # Inactive entries (drafts or archived records) are not rendered by the theme
501
+ return if entry["active"] == false
502
+
503
+ validate_export_fields(section_name, entry, item_context, lang: lang)
504
+
505
+ # 2. Section-specific schema validations
506
+ case section_name
507
+ when "experience"
508
+ validate_experience_entry(entry, item_context, lang: lang)
509
+ when "education"
510
+ validate_education_entry(entry, item_context, lang: lang)
511
+ when "certifications"
512
+ validate_certification_entry(entry, item_context, lang: lang)
513
+ when "courses"
514
+ validate_course_entry(entry, item_context, lang: lang)
515
+ when "volunteering"
516
+ validate_volunteering_entry(entry, item_context, lang: lang)
517
+ when "projects"
518
+ validate_project_entry(entry, item_context)
519
+ when "skills"
520
+ validate_skill_entry(entry, item_context)
521
+ when "recognitions"
522
+ validate_recognition_entry(entry, item_context)
523
+ when "associations"
524
+ validate_association_entry(entry, item_context)
525
+ when "languages"
526
+ validate_language_entry(entry, item_context)
527
+ when "links"
528
+ validate_link_entry(entry, item_context)
529
+ when "publications"
530
+ validate_publication_entry(entry, item_context)
531
+ when "references"
532
+ validate_reference_entry(entry, item_context)
533
+ when "interests"
534
+ validate_interest_entry(entry, item_context)
535
+ end
536
+ end
537
+
538
+ # Optional JSON Resume enrichment never replaces the HTML-facing keys.
539
+ def validate_export_fields(section, entry, context, lang:)
540
+ list_fields = {
541
+ "experience" => %w[highlights], "volunteering" => %w[highlights], "education" => %w[courses],
542
+ "skills" => %w[keywords], "interests" => %w[keywords], "projects" => %w[roles highlights keywords]
543
+ }
544
+ Array(list_fields[section]).each do |field|
545
+ next unless entry.key?(field)
546
+ next if entry[field].is_a?(Array) && entry[field].all?(String)
547
+
548
+ add_warning(context, "Optional '#{field}' must be an array of strings; invalid export values will be omitted.")
549
+ end
550
+ if section == "skills" && entry.key?("level_label") && !entry["level_label"].is_a?(String)
551
+ add_warning(context, "Optional 'level_label' must be a string.")
552
+ end
553
+ validate_url(entry["url"], context, "url") if %w[experience volunteering education publications].include?(section) && entry["url"]
554
+ if section == "projects"
555
+ validate_date(entry["startdate"], context, "startdate")
556
+ validate_date_or_present(entry["enddate"], context, "enddate", lang: lang)
557
+ validate_date_range(entry["startdate"], entry["enddate"], context, lang: lang) if entry["startdate"] && entry["enddate"]
558
+ end
559
+ validate_date(entry["date"], context, "date") if section == "recognitions"
560
+ validate_date(entry["release_date"], context, "release_date") if section == "publications"
561
+ end
562
+
563
+ def validate_active_flag(entry, context)
564
+ return unless entry.is_a?(Hash)
565
+
566
+ if entry["active"].nil?
567
+ add_warning(context, "Missing 'active' boolean flag (recommended: active: true/false)")
568
+ elsif entry["active"] != true && entry["active"] != false
569
+ add_warning(context, "'active' flag should be a boolean (true or false), got #{entry['active'].inspect}")
570
+ end
571
+ end
572
+
573
+ def validate_header(data, context)
574
+ unless data.is_a?(Hash)
575
+ add_error(context, "header.yml must be a Hash/dictionary, got #{data.class}.")
576
+ return
577
+ end
578
+
579
+ intro = data["intro"] || data["about"]
580
+ if intro.nil? || intro.to_s.strip.empty?
581
+ add_warning(context, "Header 'intro' bio summary is missing.")
582
+ elsif intro.to_s.strip.length < 20
583
+ add_warning(context, "Header 'intro' bio is very brief (< 20 characters).")
584
+ end
585
+ end
586
+
587
+ def validate_experience_entry(entry, context, lang: nil)
588
+ require_field(entry, context, "company", %w[organization])
589
+ require_field(entry, context, "position", %w[role])
590
+
591
+ has_dates = false
592
+ if entry["durations"].is_a?(Array) && entry["durations"].any?
593
+ has_dates = true
594
+ entry["durations"].each_with_index do |dur, d_idx|
595
+ if dur.is_a?(Hash) && dur["duration"].to_s.strip.empty?
596
+ add_warning(context, "Durations item ##{d_idx + 1} has empty 'duration' string.")
597
+ end
598
+ end
599
+ end
600
+
601
+ if entry["startdate"]
602
+ has_dates = true
603
+ validate_date(entry["startdate"], context, "startdate")
604
+ end
605
+
606
+ if entry["enddate"]
607
+ has_dates = true
608
+ validate_date_or_present(entry["enddate"], context, "enddate", lang: lang)
609
+ validate_date_range(entry["startdate"], entry["enddate"], context, lang: lang)
610
+ end
611
+
612
+ add_warning(context, "Role has no 'startdate' or 'durations' specified.") unless has_dates
613
+ end
614
+
615
+ def validate_education_entry(entry, context, lang: nil)
616
+ require_field(entry, context, "uni", %w[institution school], "institution/university")
617
+ require_field(entry, context, "degree")
618
+
619
+ # data-schemas.md documents education.yml with a freeform `year` string (not
620
+ # startdate/enddate) as the displayed date range; require it be present since
621
+ # nothing else here validates that education entries have a date at all.
622
+ add_error(context, "Missing required field 'year'") if entry["year"].to_s.strip.empty? && !entry["startdate"]
623
+
624
+ validate_date(entry["startdate"], context, "startdate") if entry["startdate"]
625
+ validate_date_or_present(entry["enddate"], context, "enddate", lang: lang) if entry["enddate"]
626
+ validate_date_range(entry["startdate"], entry["enddate"], context, lang: lang) if entry["startdate"] && entry["enddate"]
627
+ end
628
+
629
+ def validate_certification_entry(entry, context, lang: nil)
630
+ require_field(entry, context, "name", %w[title])
631
+
632
+ validate_date(entry["issue_date"], context, "issue_date") if entry["issue_date"]
633
+ validate_date(entry["expiration"], context, "expiration") if entry["expiration"]
634
+ validate_date_range(entry["issue_date"], entry["expiration"], context, lang: lang) if entry["issue_date"] && entry["expiration"]
635
+
636
+ validate_url(entry["credential_url"], context, "credential_url") if entry["credential_url"]
637
+ end
638
+
639
+ def validate_course_entry(entry, context, lang: nil)
640
+ require_field(entry, context, "name", %w[title course])
641
+
642
+ validate_date(entry["startdate"], context, "startdate") if entry["startdate"]
643
+ validate_date(entry["enddate"], context, "enddate") if entry["enddate"]
644
+ validate_date_range(entry["startdate"], entry["enddate"], context, lang: lang) if entry["startdate"] && entry["enddate"]
645
+
646
+ validate_url(entry["credential_url"], context, "credential_url") if entry["credential_url"]
647
+ end
648
+
649
+ def validate_volunteering_entry(entry, context, lang: nil)
650
+ require_field(entry, context, "company", %w[organization], "organization name")
651
+ require_field(entry, context, "position", %w[role])
652
+
653
+ validate_date(entry["startdate"], context, "startdate") if entry["startdate"]
654
+ validate_date_or_present(entry["enddate"], context, "enddate", lang: lang) if entry["enddate"]
655
+ validate_date_range(entry["startdate"], entry["enddate"], context, lang: lang) if entry["startdate"] && entry["enddate"]
656
+ end
657
+
658
+ def validate_project_entry(entry, context)
659
+ require_field(entry, context, "project", %w[title name])
660
+
661
+ validate_url(entry["url"], context, "url") if entry["url"]
662
+ end
663
+
664
+ def validate_skill_entry(entry, context)
665
+ require_field(entry, context, "skill", %w[category name])
666
+
667
+ return unless entry["level"]
668
+
669
+ lvl = entry["level"].to_i
670
+ return if (1..5).cover?(lvl)
671
+
672
+ add_warning(context, "Skill 'level' (#{entry['level']}) should be an integer between 1 and 5.")
673
+ end
674
+
675
+ def validate_recognition_entry(entry, context)
676
+ require_field(entry, context, "award", %w[title recognition])
677
+ end
678
+
679
+ def validate_association_entry(entry, context)
680
+ require_field(entry, context, "organization", %w[company name])
681
+
682
+ validate_url(entry["url"], context, "url") if entry["url"]
683
+ end
684
+
685
+ def validate_language_entry(entry, context)
686
+ require_field(entry, context, "language", %w[name])
687
+ end
688
+
689
+ def validate_link_entry(entry, context)
690
+ require_field(entry, context, "description", %w[name title])
691
+
692
+ if entry["url"].to_s.strip.empty?
693
+ add_error(context, "Missing required field 'url'")
694
+ else
695
+ validate_url(entry["url"], context, "url")
696
+ end
697
+ end
698
+
699
+ def validate_publication_entry(entry, context)
700
+ require_field(entry, context, "name", %w[title])
701
+ end
702
+
703
+ def validate_reference_entry(entry, context)
704
+ require_field(entry, context, "name")
705
+ require_field(entry, context, "reference", %w[quote text])
706
+ end
707
+
708
+ def validate_interest_entry(entry, context)
709
+ return unless entry["description"].to_s.strip.empty?
710
+
711
+ found = %w[interest name].find { |key| !entry[key].to_s.strip.empty? }
712
+ add_warning(context, "Missing 'description' string#{alias_hint(found, 'description')}")
713
+ end
714
+
715
+ # The templates render only the canonical key, so an entry that sets just an alias
716
+ # (e.g. `organization` instead of `company`) would validate yet render blank.
717
+ def require_field(entry, context, field, aliases = [], label = nil)
718
+ return unless entry[field].to_s.strip.empty?
719
+
720
+ found = aliases.find { |key| !entry[key].to_s.strip.empty? }
721
+ add_error(context, "Missing required field '#{field}'#{" (#{label})" if label}#{alias_hint(found, field)}")
722
+ end
723
+
724
+ def alias_hint(found, field)
725
+ found ? ": found '#{found}', but the theme only renders '#{field}'" : ""
726
+ end
727
+
728
+ # Helper: Validates date syntax against ISO 8601 (YYYY-MM-DD, YYYY-MM, or YYYY).
729
+ # Delegates the actual calendar-validity check to parse_date_safely so the
730
+ # Date.iso8601/strptime/new logic exists in one place.
731
+ def validate_date(date_val, context, field_name)
732
+ return if date_val.nil?
733
+ return if date_val.is_a?(Date) || date_val.is_a?(Time)
734
+
735
+ str = date_val.to_s.strip
736
+ return if str.empty?
737
+ return if STRICT_ISO_DATE_REGEX.match?(str) && parse_date_safely(str)
738
+
739
+ add_error(context, "Invalid date format for '#{field_name}': '#{date_val}' (expected ISO YYYY-MM-DD, YYYY-MM, or YYYY)")
740
+ end
741
+
742
+ # Helper: Validates date or localized 'Present' alias
743
+ def validate_date_or_present(date_val, context, field_name, lang: nil)
744
+ return if date_val.nil?
745
+
746
+ str = date_val.to_s.strip
747
+ return if str.empty? || present_date?(date_val, lang: lang)
748
+
749
+ validate_date(date_val, context, field_name)
750
+ end
751
+
752
+ # Helper: Ensures end date >= start date (handling localized 'Present' aliases and partial dates)
753
+ def validate_date_range(start_val, end_val, context, lang: nil)
754
+ return if start_val.nil? || end_val.nil?
755
+
756
+ s_str = start_val.to_s.strip
757
+ e_str = end_val.to_s.strip
758
+ return if s_str.empty? || e_str.empty? || present_date?(end_val, lang: lang)
759
+
760
+ s_date = parse_date_safely(start_val)
761
+ e_date = parse_date_safely(end_val, end_of_period: true)
762
+
763
+ return unless s_date && e_date && e_date < s_date
764
+
765
+ add_error(context, "Date range error: end date (#{e_date}) is before start date (#{s_date}).")
766
+ end
767
+
768
+ # Parses ISO dates, partial dates (YYYY-MM, YYYY), Date/Time objects, and strings safely into Date objects.
769
+ # When end_of_period is true, partial dates resolve to the end of that period (last day of month / year).
770
+ def parse_date_safely(val, end_of_period: false)
771
+ return val if val.is_a?(Date)
772
+ return val.to_date if val.is_a?(Time)
773
+ return nil if val.nil?
774
+
775
+ str = val.to_s.strip
776
+ return nil if str.empty?
777
+
778
+ case str
779
+ when /^\d{4}-\d{2}-\d{2}$/
780
+ Date.iso8601(str)
781
+ when /^(\d{4})-(\d{2})$/
782
+ year = ::Regexp.last_match(1).to_i
783
+ month = ::Regexp.last_match(2).to_i
784
+ if end_of_period
785
+ Date.new(year, month, -1)
786
+ else
787
+ Date.new(year, month, 1)
788
+ end
789
+ when /^(\d{4})$/
790
+ year = ::Regexp.last_match(1).to_i
791
+ if end_of_period
792
+ Date.new(year, 12, 31)
793
+ else
794
+ Date.new(year, 1, 1)
795
+ end
796
+ else
797
+ Date.parse(str)
798
+ end
799
+ rescue StandardError
800
+ nil
801
+ end
802
+
803
+ # Helper: Validates HTTP / HTTPS URL syntax. Non-http(s) schemes (javascript:, data:, ...)
804
+ # are rejected outright: resume-section.html renders these fields into raw href attributes
805
+ # with no escaping, and there is no legitimate reason a resume link needs another scheme.
806
+ def validate_url(url_val, context, field_name)
807
+ return if url_val.nil? || url_val.to_s.strip.empty?
808
+
809
+ uri = URI.parse(url_val.to_s.strip)
810
+ unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
811
+ add_error(context, "#{field_name} '#{url_val}' must begin with http:// or https://")
812
+ end
813
+ rescue URI::InvalidURIError
814
+ add_error(context, "Invalid URL format for '#{field_name}': '#{url_val}'")
815
+ end
816
+
817
+ def add_error(context, msg)
818
+ @errors << { context: context, message: msg }
819
+ end
820
+
821
+ def add_warning(context, msg)
822
+ @warnings << { context: context, message: msg }
823
+ end
824
+
825
+ def report_results
826
+ puts "\n"
827
+ print_findings(@errors, :red, "✗", "VALIDATION ERRORS")
828
+ print_findings(@warnings, :yellow, "⚠", "VALIDATION WARNINGS")
829
+
830
+ puts color(:cyan, "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
831
+ if @errors.empty?
832
+ status = @warnings.empty? ? "clean, 0 warnings" : "#{@warnings.size} warning(s)"
833
+ puts color(:green, "✓ VALIDATION SUCCESSFUL: All resume data files are valid! (#{status})")
834
+ else
835
+ puts color(:red, "✗ VALIDATION FAILED: #{@errors.size} error(s), #{@warnings.size} warning(s)")
836
+ end
837
+ puts color(:cyan, "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n")
838
+ end
839
+
840
+ # Prints a boxed banner followed by one "<icon> context / → message" pair per finding.
841
+ def print_findings(findings, tone, icon, title)
842
+ return if findings.empty?
843
+
844
+ puts color(tone, "╔═══════════════════════════════════════════════════════════════╗")
845
+ puts color(tone, "║#{"#{title} (#{findings.size.to_s.rjust(2)})".center(63)}║")
846
+ puts color(tone, "╚═══════════════════════════════════════════════════════════════╝")
847
+ findings.each do |finding|
848
+ puts " #{color(tone, icon)} #{color(:bold, finding[:context])}"
849
+ puts " → #{finding[:message]}\n"
850
+ end
851
+ end
852
+ end
853
+ end