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.
- checksums.yaml +7 -0
- data/403.html +6 -0
- data/404.html +6 -0
- data/500.html +6 -0
- data/CHANGELOG.md +498 -0
- data/CODE_OF_CONDUCT.md +92 -0
- data/LICENSE.txt +22 -0
- data/README.md +184 -0
- data/SECURITY.md +30 -0
- data/_config.sample.yml +329 -0
- data/_data/locales/ar.yml +85 -0
- data/_data/locales/de.yml +86 -0
- data/_data/locales/en.yml +84 -0
- data/_data/locales/es.yml +86 -0
- data/_data/locales/fr.yml +86 -0
- data/_data/locales/ur.yml +86 -0
- data/_data/social_networks.yml +83 -0
- data/_includes/analytics-body.html +12 -0
- data/_includes/analytics-head.html +36 -0
- data/_includes/avatar.html +84 -0
- data/_includes/dark-mode-toggle.html +202 -0
- data/_includes/data-loader.html +42 -0
- data/_includes/date-formatter.html +63 -0
- data/_includes/grouped-item-list.html +53 -0
- data/_includes/hreflang.html +58 -0
- data/_includes/language-switcher.html +72 -0
- data/_includes/print-social-links.html +23 -0
- data/_includes/resume-section.html +547 -0
- data/_includes/safe-url.html +21 -0
- data/_includes/shared-head.html +51 -0
- data/_includes/social-links.html +54 -0
- data/_includes/vendors/svg-icons/ATTRIBUTION.md +34 -0
- data/_includes/vendors/svg-icons/dev.svg +3 -0
- data/_includes/vendors/svg-icons/dribbble-symbol.svg +3 -0
- data/_includes/vendors/svg-icons/envelope.svg +4 -0
- data/_includes/vendors/svg-icons/facebook.svg +3 -0
- data/_includes/vendors/svg-icons/flickr.svg +4 -0
- data/_includes/vendors/svg-icons/github.svg +3 -0
- data/_includes/vendors/svg-icons/globe-1.svg +3 -0
- data/_includes/vendors/svg-icons/instagram.svg +4 -0
- data/_includes/vendors/svg-icons/linkedin.svg +3 -0
- data/_includes/vendors/svg-icons/medium.svg +3 -0
- data/_includes/vendors/svg-icons/phone.svg +13 -0
- data/_includes/vendors/svg-icons/pinterest.svg +3 -0
- data/_includes/vendors/svg-icons/postcard.svg +14 -0
- data/_includes/vendors/svg-icons/telegram.svg +3 -0
- data/_includes/vendors/svg-icons/whatsapp.svg +3 -0
- data/_includes/vendors/svg-icons/x.svg +3 -0
- data/_includes/vendors/svg-icons/youtube.svg +3 -0
- data/_layouts/default.html +65 -0
- data/_layouts/error.html +138 -0
- data/_layouts/profile.html +123 -0
- data/_layouts/resume.html +238 -0
- data/_plugins/error_pages_generator.rb +71 -0
- data/_plugins/json_resume_generator.rb +79 -0
- data/_plugins/resume_pages_generator.rb +71 -0
- data/_plugins/resume_validator.rb +40 -0
- data/_sass/_all-pages.scss +335 -0
- data/_sass/_base.scss +130 -0
- data/_sass/_dark-mode.scss +387 -0
- data/_sass/_layout.scss +116 -0
- data/_sass/_mixins.scss +136 -0
- data/_sass/_normalize.scss +379 -0
- data/_sass/_profile-page.scss +60 -0
- data/_sass/_resume-ltr.scss +436 -0
- data/_sass/_resume-rtl.scss +91 -0
- data/_sass/_variables.scss +45 -0
- data/assets/css/cv-ltr.scss +31 -0
- data/assets/css/cv-rtl.scss +36 -0
- data/assets/css/main.scss +8 -0
- data/assets/css/profile.scss +9 -0
- data/assets/favicon/resume/about.txt +6 -0
- data/assets/favicon/resume/android-chrome-192x192.png +0 -0
- data/assets/favicon/resume/android-chrome-512x512.png +0 -0
- data/assets/favicon/resume/apple-touch-icon.png +0 -0
- data/assets/favicon/resume/favicon-16x16.png +0 -0
- data/assets/favicon/resume/favicon-32x32.png +0 -0
- data/assets/favicon/resume/favicon.ico +0 -0
- data/assets/favicon/resume/site.webmanifest +1 -0
- data/bin/validate-resume +89 -0
- data/bin/verify +27 -0
- data/docs/README.md +89 -0
- data/docs/explanation/accessibility-decisions.md +27 -0
- data/docs/explanation/architecture.md +44 -0
- data/docs/explanation/dark-mode-approach.md +25 -0
- data/docs/explanation/data-driven-model.md +25 -0
- data/docs/explanation/multilingual-and-rtl-design.md +40 -0
- data/docs/how-to/add-a-language.md +57 -0
- data/docs/how-to/add-a-section.md +35 -0
- data/docs/how-to/add-a-social-platform.md +32 -0
- data/docs/how-to/add-a-test.md +34 -0
- data/docs/how-to/create-a-custom-layout.md +64 -0
- data/docs/how-to/enable-dark-mode.md +43 -0
- data/docs/how-to/link-translations-with-hreflang.md +25 -0
- data/docs/how-to/migrate-v0.9-to-v1.0.md +46 -0
- data/docs/how-to/override-locale-strings.md +51 -0
- data/docs/how-to/override-sass-partials.md +26 -0
- data/docs/how-to/proof-built-html.md +22 -0
- data/docs/how-to/publish-json-resume.md +38 -0
- data/docs/how-to/show-language-proficiency-in-header.md +13 -0
- data/docs/how-to/switch-resume-versions.md +42 -0
- data/docs/how-to/troubleshoot-builds.md +39 -0
- data/docs/how-to/validate-in-ci.md +38 -0
- data/docs/how-to/verify-accessibility.md +28 -0
- data/docs/reference/accessibility-coverage.md +39 -0
- data/docs/reference/config.md +378 -0
- data/docs/reference/data-schemas.md +529 -0
- data/docs/reference/glossary.md +39 -0
- data/docs/reference/includes.md +197 -0
- data/docs/reference/json-resume-fields.md +148 -0
- data/docs/reference/layouts.md +144 -0
- data/docs/reference/locale-keys.md +76 -0
- data/docs/reference/repository-map.md +83 -0
- data/docs/reference/sass-tokens.md +279 -0
- data/docs/reference/testing-suites.md +62 -0
- data/docs/reference/validator-cli.md +203 -0
- data/docs/tutorials/getting-started.md +173 -0
- data/lib/jekyll-theme-resume/json_resume_exporter.rb +334 -0
- data/lib/jekyll-theme-resume/resume_validator.rb +853 -0
- data/lib/jekyll-theme-resume/schemas/LICENSE.md +21 -0
- data/lib/jekyll-theme-resume/schemas/json_resume_v1.0.0.json +500 -0
- data/lib/jekyll-theme-resume.rb +21 -0
- 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
|