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,173 @@
1
+ # Getting Started: Your First Two-Language Resume
2
+
3
+ *Audience: site owners*
4
+
5
+ In this tutorial you start from an empty folder and end with an English and Arabic resume site running on your computer, built with the `jekyll-theme-resume` gem. Follow the steps in order. Each step shows what you should see before you move on.
6
+
7
+ ## Before you start
8
+
9
+ You need:
10
+
11
+ - **Theme version 1.3.0 or newer.** This tutorial is written for 1.3.0.
12
+ - **Ruby 3.3.0 or newer.** The theme's CI tests Ruby 3.3, 3.4, and 4.0.
13
+ - **Bundler**, which installs Jekyll and the theme for you. The theme needs Jekyll `~> 4.4`, and Bundler installs it automatically.
14
+ - **Git**, used in step 5 to download the sample resume data.
15
+
16
+ Check your tools:
17
+
18
+ ```bash
19
+ ruby -v
20
+ bundle -v
21
+ git --version
22
+ ```
23
+
24
+ **Expected result:** `ruby -v` prints `ruby 3.3.0` or higher, and the other two commands print a version number.
25
+
26
+ ## Step 1: Create the site folder
27
+
28
+ ```bash
29
+ mkdir my-resume
30
+ cd my-resume
31
+ ```
32
+
33
+ **Expected result:** you are inside an empty folder named `my-resume`. All later commands run from here.
34
+
35
+ ## Step 2: Add a Gemfile
36
+
37
+ Create a file named `Gemfile` with this content:
38
+
39
+ ```ruby
40
+ source "https://rubygems.org"
41
+
42
+ group :jekyll_plugins do
43
+ gem "jekyll-theme-resume"
44
+ end
45
+ ```
46
+
47
+ The theme must be inside `group :jekyll_plugins`. That group loads the theme's build-time generators and validator. A plain `gem` line outside the group does not load them.
48
+
49
+ ## Step 3: Install the theme
50
+
51
+ ```bash
52
+ bundle install
53
+ ```
54
+
55
+ **Expected result:** Bundler installs `jekyll-theme-resume`, Jekyll, and the theme's plugins, then prints `Bundle complete!`.
56
+
57
+ ## Step 4: Write `_config.yml`
58
+
59
+ Create `_config.yml` with this minimal English and Arabic configuration:
60
+
61
+ ```yaml
62
+ theme: jekyll-theme-resume
63
+ title: "Jane Doe"
64
+ url: "https://your-domain.com"
65
+ baseurl: "" # Keep empty unless hosting on a subpath (e.g., /resume)
66
+ timezone: UTC
67
+
68
+ languages:
69
+ en:
70
+ data_path: en # _data/en/*
71
+ url: /en/cv/
72
+ header_intro: true
73
+ name: "Jane Doe"
74
+ resume_title: "Senior Product Manager"
75
+ ar:
76
+ data_path: ar # _data/ar/*
77
+ url: /ar/cv/
78
+ header_intro: true
79
+ name: "جين دو"
80
+ resume_title: "مديرة منتج أولى"
81
+
82
+ default_lang: en
83
+
84
+ contact_info:
85
+ email: "jane.doe@example.com"
86
+
87
+ resume_section:
88
+ experience: true
89
+ education: true
90
+ projects: true
91
+ skills: true
92
+
93
+ resume_section_order:
94
+ - experience
95
+ - education
96
+ - projects
97
+ - skills
98
+ ```
99
+
100
+ This file tells the theme to publish two languages. Each language reads its resume content from the folder named by `data_path`, and its CV lives at `url`. You do not create page files yourself: the theme generates each language's CV at its `url`, a profile landing page at `/` for the default language, and one at `/<lang>/` for every other language. For now, replace only the `name` and `resume_title` under each language, and the email, with your own if you want to. The name shown in the page header comes from `languages.<lang>.name`. Every other key is covered in the [Configuration reference](../reference/config.md).
101
+
102
+ ## Step 5: Copy the starter resume data
103
+
104
+ The theme's demo site has a complete sample resume (Sherlock Holmes) in six languages. Download the demo and copy the English and Arabic folders into your site:
105
+
106
+ ```bash
107
+ git clone https://github.com/kmutahar/bilingual-jekyll-resume-demo.git ../bilingual-jekyll-resume-demo
108
+ mkdir -p _data
109
+ cp -r ../bilingual-jekyll-resume-demo/_data/en ../bilingual-jekyll-resume-demo/_data/ar _data/
110
+ ```
111
+
112
+ The clone creates a folder named `bilingual-jekyll-resume-demo` next to `my-resume`, not inside it. You only need it for this copy.
113
+
114
+ **Expected result:** `ls _data/en` and `ls _data/ar` each list 13 YAML files, from `associations.yml` to `volunteering.yml`, including `header.yml`, which holds the intro paragraph shown under your name.
115
+
116
+ ## Step 6: Start the site
117
+
118
+ ```bash
119
+ bundle exec jekyll serve
120
+ ```
121
+
122
+ **Expected result:** the output includes these lines:
123
+
124
+ ```text
125
+ ✓ VALIDATION SUCCESSFUL: All resume data files are valid! (clean, 0 warnings)
126
+ Server address: http://127.0.0.1:4000/
127
+ Server running... press ctrl-c to stop.
128
+ ```
129
+
130
+ Leave this command running.
131
+
132
+ ## Step 7: Open your resume
133
+
134
+ Open these addresses in your browser:
135
+
136
+ | URL | What you see |
137
+ |---|---|
138
+ | `http://localhost:4000/` | The English profile landing page. |
139
+ | `http://localhost:4000/ar/` | The Arabic profile landing page. |
140
+ | `http://localhost:4000/en/cv/` | The English CV, left to right, headed "Jane Doe" and "Senior Product Manager", with Experience, Education, Projects, and Skills sections. |
141
+ | `http://localhost:4000/ar/cv/` | The Arabic CV, laid out right to left, headed "جين دو". |
142
+
143
+ On either page, use the floating language switcher to jump to the other language.
144
+
145
+ The section content is still the Sherlock Holmes sample. Only the header shows your name.
146
+
147
+ ## Step 8: Make it yours
148
+
149
+ With the server still running, open `_data/en/header.yml`, replace the paragraph under `intro:` with a sentence about yourself, and save the file. The paragraph is a folded block (`intro: >-`), so keep your text indented under it. Reload `http://localhost:4000/en/cv/`.
150
+
151
+ **Expected result:** the new intro appears under your name. Jekyll rebuilds when you save a data file. Changes to `_config.yml` need a restart: stop the server with `ctrl-c` and run `bundle exec jekyll serve` again.
152
+
153
+ Replace the rest of the sample content the same way, one file at a time, in both `_data/en/` and `_data/ar/`. Write the Arabic content in Arabic. If you copy English text into `_data/ar/`, the headings will be in Arabic but the body will stay in English. If a change breaks something, the validation report in the `jekyll serve` output lists the problems it found in your data files.
154
+
155
+ You now have a working two-language resume site.
156
+
157
+ ## Where to go next
158
+
159
+ - Look up what each field in the 13 data files means in [Data schemas](../reference/data-schemas.md).
160
+ - Look up every `_config.yml` key, including avatar, social links, and dark mode, in [Configuration](../reference/config.md).
161
+ - Add a third language with [Add a language](../how-to/add-a-language.md).
162
+ - If the build fails or a section is missing, see [Troubleshoot builds](../how-to/troubleshoot-builds.md).
163
+ - To learn how one layout renders both LTR and RTL languages, read [Multilingual and RTL design](../explanation/multilingual-and-rtl-design.md) and [The data-driven model](../explanation/data-driven-model.md).
164
+
165
+ To see all sections types in all six languages, build the full Sherlock Holmes demo from a clone of the theme repository. Run these commands from a folder outside `my-resume`; the first two clone the repository and move into it:
166
+
167
+ ```bash
168
+ git clone https://github.com/kmutahar/jekyll-theme-resume.git
169
+ cd jekyll-theme-resume
170
+ git submodule update --init --recursive
171
+ bundle install
172
+ bundle exec jekyll build --source demo --destination _site
173
+ ```
@@ -0,0 +1,334 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "date"
5
+ require "time"
6
+ require "cgi"
7
+ require "addressable/uri"
8
+ require "kramdown/utils/entities"
9
+ require "json_schemer"
10
+
11
+ module JekyllThemeResume
12
+ # Maps the existing CV data contract to JSON Resume without changing source keys.
13
+ # Returns nil when final validation fails; callers must never publish that export.
14
+ class JsonResumeExporter
15
+ SCHEMA_URL = "https://raw.githubusercontent.com/jsonresume/resume-schema/v1.0.0/schema.json"
16
+ SCHEMA = JSONSchemer.schema(JSON.parse(File.read(File.join(__dir__, "schemas/json_resume_v1.0.0.json"))))
17
+ SECTIONS = {
18
+ "experience" => "work", "volunteering" => "volunteer", "education" => "education",
19
+ "certifications" => "certificates", "recognitions" => "awards", "skills" => "skills",
20
+ "languages" => "languages", "interests" => "interests", "projects" => "projects",
21
+ "publications" => "publications", "references" => "references"
22
+ }.freeze
23
+ FIELDS = {
24
+ "experience" => { "name" => "company", "position" => "position", "location" => "location" },
25
+ "volunteering" => { "organization" => "company", "position" => "position" },
26
+ "education" => { "institution" => "uni", "studyType" => %w[degree study_type], "area" => "area", "score" => %w[score gpa] },
27
+ "certifications" => { "name" => "name", "issuer" => "issuing_organization" },
28
+ "recognitions" => { "title" => %w[award title recognition], "awarder" => "organization", "summary" => "summary" },
29
+ "skills" => { "name" => "skill", "level" => "level_label" },
30
+ "languages" => { "language" => "language" },
31
+ "interests" => { "name" => %w[name description] },
32
+ "projects" => { "name" => "project", "description" => "description" },
33
+ "publications" => { "name" => "name", "publisher" => "publisher", "summary" => "summary" },
34
+ "references" => { "name" => "name", "reference" => "reference" }
35
+ }.freeze
36
+ LIST_FIELDS = {
37
+ "experience" => %w[highlights], "volunteering" => %w[highlights], "education" => %w[courses],
38
+ "skills" => %w[keywords], "interests" => %w[keywords], "projects" => %w[highlights keywords]
39
+ }.freeze
40
+ NETWORKS = %w[github linkedin telegram twitter medium dribbble facebook instagram website whatsapp devto flickr pinterest
41
+ youtube].freeze
42
+ COUNTRY_CODES = %w[
43
+ AD AE AF AG AI AL AM AO AQ AR AS AT AU AW AX AZ BA BB BD BE BF BG BH BI BJ BL BM BN BO BQ BR BS BT BV BW BY BZ
44
+ CA CC CD CF CG CH CI CK CL CM CN CO CR CU CV CW CX CY CZ DE DJ DK DM DO DZ EC EE EG EH ER ES ET FI FJ FK FM FO FR
45
+ GA GB GD GE GF GG GH GI GL GM GN GP GQ GR GS GT GU GW GY HK HM HN HR HT HU ID IE IL IM IN IO IQ IR IS IT JE JM JO JP
46
+ KE KG KH KI KM KN KP KR KW KY KZ LA LB LC LI LK LR LS LT LU LV LY MA MC MD ME MF MG MH MK ML MM MN MO MP MQ MR MS
47
+ MT MU MV MW MX MY MZ NA NC NE NF NG NI NL NO NP NR NU NZ OM PA PE PF PG PH PK PL PM PN PR PS PT PW PY QA RE RO RS
48
+ RU RW SA SB SC SD SE SG SH SI SJ SK SL SM SN SO SR SS ST SV SX SY SZ TC TD TF TG TH TJ TK TL TM TN TO TR TT TV TW
49
+ TZ UA UG UM US UY UZ VA VC VE VG VI VN VU WF WS YE YT ZA ZM ZW
50
+ ].freeze
51
+
52
+ def self.export(site, lang)
53
+ new(site, lang).export
54
+ end
55
+
56
+ def initialize(site, lang)
57
+ @site = site
58
+ @config = site.config
59
+ @lang = lang
60
+ @language = hash(hash(@config["languages"])[lang])
61
+ @options = hash(@config["json_resume"])
62
+ @sections = hash(@config["resume_section"])
63
+ @data = @language["data_path"].to_s.split(".").reduce(site.data) { |data, key| hash(data)[key] }
64
+ @data = hash(@data)
65
+ locales = hash(site.data["locales"])
66
+ @locale = hash(locales[lang] || locales[@config["default_lang"]])
67
+ end
68
+
69
+ def export
70
+ result = { "$schema" => SCHEMA_URL, "basics" => basics }
71
+ result["meta"] = metadata(result["basics"]["url"])
72
+ SECTIONS.each do |section, target|
73
+ next unless visible_section?(section)
74
+
75
+ result[target] = entries(section).map { |entry| map_entry(section, entry) }.reject(&:empty?)
76
+ end
77
+ result = compact(result)
78
+ errors = SCHEMA.validate(result).to_a
79
+ return result if errors.empty?
80
+
81
+ # Never log resume values or contact data in build output.
82
+ paths = errors.map { |error| error["data_pointer"] }.uniq.join(", ")
83
+ Jekyll.logger.error "JSON Resume:", "#{@lang}: schema validation failed at #{paths}; skipping export"
84
+ nil
85
+ end
86
+
87
+ private
88
+
89
+ def hash(value)
90
+ value.is_a?(Hash) ? value : {}
91
+ end
92
+
93
+ def compact(value)
94
+ case value
95
+ when Hash
96
+ value.transform_values { |item| compact(item) }.reject { |_key, item| empty?(item) }
97
+ when Array
98
+ value.map { |item| compact(item) }.reject { |item| empty?(item) }
99
+ else
100
+ value
101
+ end
102
+ end
103
+
104
+ def empty?(value)
105
+ value.nil? || (value.respond_to?(:empty?) && value.empty?)
106
+ end
107
+
108
+ def text(value)
109
+ return unless value.is_a?(String) || value.is_a?(Numeric)
110
+
111
+ # Decode entities first: an encoded tag like &lt;script&gt; must become
112
+ # a literal tag *before* strip_html runs, or it survives untouched.
113
+ decoded = value.to_s.gsub(/&(#x[0-9a-f]+|#\d+|[a-z][a-z0-9]+);/i) { |entity| decode_entity(entity) }
114
+ strip_html(decoded).gsub("\r\n", "\n").tr("\u00a0", " ").gsub(/\n{3,}/, "\n\n").strip
115
+ end
116
+
117
+ # Single-pass regex tag removal can leave a match behind: deleting one
118
+ # fragment can make two separated pieces adjacent and form a new tag a
119
+ # single pass never rechecks. Loop to a fixed point so nothing survives.
120
+ def strip_html(str)
121
+ loop do
122
+ stripped = str.gsub(%r{<script\b[^>]*>.*?</\s*script(?:[\s/][^>]*)?>}mi, "")
123
+ .gsub(%r{<br\s*/?\s*>|</\s*(?:p|div|li|ul|ol|h[1-6])\s*>}i, "\n")
124
+ .gsub(%r{</?[a-zA-Z][\w:-]*(?:\s[^<>]*?)?\s*/?>}, "")
125
+ break str if stripped == str
126
+
127
+ str = stripped
128
+ end
129
+ end
130
+
131
+ def decode_entity(entity)
132
+ return CGI.unescapeHTML(entity) if entity.start_with?("&#")
133
+
134
+ Kramdown::Utils::Entities.entity(entity[1...-1]).code_point.chr(Encoding::UTF_8)
135
+ rescue Kramdown::Error, RangeError
136
+ entity
137
+ end
138
+
139
+ def warning(field, reason)
140
+ Jekyll.logger.warn "JSON Resume:", "#{@lang}.#{field}: #{reason}; omitted"
141
+ nil
142
+ end
143
+
144
+ def first(entry, fields)
145
+ Array(fields).map { |field| entry[field] }.find { |value| !empty?(value) }
146
+ end
147
+
148
+ def truthy?(value)
149
+ !value.nil? && value != false
150
+ end
151
+
152
+ def visible_section?(section)
153
+ if section == "languages"
154
+ return true if header_languages?
155
+ return false if @sections["lang_header"] == true
156
+ end
157
+ truthy?(@sections[section]) && Array(@config["resume_section_order"]).include?(section)
158
+ end
159
+
160
+ def header_languages?
161
+ @config["display_header_contact_info"] == true && truthy?(@sections["lang_header"])
162
+ end
163
+
164
+ def entries(section)
165
+ list = @data[section]
166
+ return [] unless list.is_a?(Array)
167
+
168
+ list = list.select { |entry| entry.is_a?(Hash) && (section == "interests" || entry["active"] == true) }
169
+ return list unless %w[experience volunteering].include?(section)
170
+
171
+ list.group_by { |entry| entry["company"] }.values.flat_map do |roles|
172
+ roles.sort_by { |entry| entry["startdate"].to_s }.reverse
173
+ end
174
+ end
175
+
176
+ def basics
177
+ result = { "name" => text(@language["name"]), "label" => text(@language["resume_title"]), "url" => canonical_url }
178
+ result["summary"] = text(hash(@data["header"])["intro"]) if @language["header_intro"] == true
179
+ if @config["resume_avatar"] == true
180
+ result["image"] = absolute_url(first(@config, "avatar_url") || "/assets/images/Profile-min.jpg", "basics.image")
181
+ end
182
+ result.merge!(contacts) if export_contacts?
183
+ result["profiles"] = profiles
184
+ result
185
+ end
186
+
187
+ def export_contacts?
188
+ hash(@options["privacy"])["export_contact_info"] != false
189
+ end
190
+
191
+ def contacts
192
+ result = {}
193
+ if @config["display_header_contact_info"] == true
194
+ result["phone"] = text(contact("phone"))
195
+ result["location"] = location
196
+ end
197
+ if @config["display_header_contact_info"] == true || @config["resume_looking_for_work"] == true
198
+ email = text(contact("email"))
199
+ result["email"] = valid_format(email, "email", "basics.email")
200
+ end
201
+ result
202
+ end
203
+
204
+ def contact(key)
205
+ info = hash(@config["contact_info"])
206
+ live = info["#{key}_live"]
207
+ @config["enable_live"] == true && truthy?(live) ? live : info[key]
208
+ end
209
+
210
+ def location
211
+ result = { "address" => text(@language["address"]), "postalCode" => text(@language["postal_code"]),
212
+ "city" => text(@language["city"]), "region" => text(@language["region"]) }
213
+ country = text(@language["country_code"])
214
+ result["countryCode"] = if empty?(country) || COUNTRY_CODES.include?(country)
215
+ country
216
+ else
217
+ warning("basics.location.countryCode", "expected ISO-3166-1 alpha-2 code")
218
+ end
219
+ result
220
+ end
221
+
222
+ def profiles
223
+ socials = hash(@config["social_links"])
224
+ usernames = hash(@config["social_usernames"])
225
+ NETWORKS.filter_map do |network|
226
+ next if network == "whatsapp" && !export_contacts?
227
+ next if empty?(socials[network])
228
+
229
+ url = absolute_url(socials[network], "basics.profiles.#{network}.url", relative: false)
230
+ next unless url
231
+
232
+ { "network" => network, "username" => text(usernames[network]), "url" => url }
233
+ end
234
+ end
235
+
236
+ def canonical_url
237
+ page = @site.pages.find { |item| item.data["layout"] == "resume" && item.data["lang"] == @lang }
238
+ absolute_url(page ? page.url : @language["url"], "basics.url")
239
+ end
240
+
241
+ def metadata(canonical)
242
+ { "version" => "1.0.0", "lastModified" => @site.time.getutc.iso8601, "canonical" => canonical }
243
+ end
244
+
245
+ def map_entry(section, entry)
246
+ result = FIELDS.fetch(section).to_h { |target, source| [target, text(first(entry, source))] }
247
+ Array(LIST_FIELDS[section]).each do |field|
248
+ result[field] = string_list(entry[field], "#{section}.#{field}")
249
+ end
250
+ add_dates_and_url(result, section, entry)
251
+ result["summary"] = text(entry["summary"]) if %w[experience volunteering].include?(section) && @config["enable_summary"] == true
252
+ if section == "languages"
253
+ result["fluency"] = text(entry[header_languages? ? "descrp_short" : "description"])
254
+ end
255
+ result["roles"] = project_roles(entry) if section == "projects"
256
+ compact(result)
257
+ end
258
+
259
+ def project_roles(entry)
260
+ return string_list(entry["roles"], "projects.roles") if entry.key?("roles")
261
+
262
+ [text(entry["role"])]
263
+ end
264
+
265
+ def string_list(value, field)
266
+ return if value.nil?
267
+ return warning(field, "expected an array of strings") unless value.is_a?(Array)
268
+
269
+ value.filter_map do |item|
270
+ item.is_a?(String) ? text(item) : warning(field, "expected a string item")
271
+ end
272
+ end
273
+
274
+ def add_dates_and_url(result, section, entry)
275
+ if %w[experience volunteering education projects].include?(section)
276
+ result["startDate"] = date(entry["startdate"], "#{section}.startdate")
277
+ result["endDate"] = date(entry["enddate"], "#{section}.enddate", ongoing: true)
278
+ result["url"] = absolute_url(entry["url"], "#{section}.url")
279
+ elsif section == "certifications"
280
+ result["date"] = date(entry["issue_date"], "certifications.issue_date", full: true)
281
+ result["url"] = absolute_url(entry["credential_url"], "certifications.credential_url")
282
+ elsif section == "publications"
283
+ result["releaseDate"] = date(entry["release_date"], "publications.release_date")
284
+ result["url"] = absolute_url(entry["url"], "publications.url")
285
+ elsif section == "recognitions"
286
+ value = entry["date"] || (entry["year"] if entry["year"].to_s.match?(/\A\d{4}\z/))
287
+ result["date"] = date(value, "recognitions.date")
288
+ end
289
+ end
290
+
291
+ def date(value, field, ongoing: false, full: false)
292
+ return if value.nil?
293
+
294
+ value = value.strftime("%Y-%m-%d") if value.is_a?(Date) || value.is_a?(Time)
295
+ str = value.to_s.strip
296
+ return if str.empty?
297
+ return if ongoing && (["present"] + Array(@locale["present_values"]).map { |item| item.to_s.downcase }).include?(str.downcase)
298
+
299
+ pattern = full ? /\A[12]\d{3}-\d{2}-\d{2}\z/ : /\A[12]\d{3}(?:-\d{2}(?:-\d{2})?)?\z/
300
+ raise ArgumentError unless pattern.match?(str)
301
+
302
+ parts = str.split("-").map(&:to_i)
303
+ Date.new(parts[0], parts[1] || 1, parts[2] || 1)
304
+ str
305
+ rescue ArgumentError
306
+ warning(field, full ? "expected a valid YYYY-MM-DD date" : "expected a valid ISO date")
307
+ end
308
+
309
+ def valid_format(value, format, field)
310
+ return if empty?(value)
311
+ return value if JSONSchemer.schema({ "type" => "string", "format" => format }).valid?(value)
312
+
313
+ warning(field, "invalid #{format}")
314
+ end
315
+
316
+ def absolute_url(value, field, relative: true)
317
+ return if empty?(value)
318
+ return warning(field, "expected a URL string") unless value.is_a?(String)
319
+
320
+ uri = Addressable::URI.parse(value.strip)
321
+ if uri.relative? && relative && !@config["url"].to_s.empty?
322
+ root = @config["url"].to_s.sub(%r{/+\z}, "")
323
+ base = @config["baseurl"].to_s.gsub(%r{\A/+|/+\z}, "")
324
+ uri = Addressable::URI.parse([root, base, value.sub(%r{\A/+}, "")].reject(&:empty?).join("/"))
325
+ end
326
+ return warning(field, "expected an absolute HTTP(S) URL (configure site.url for local paths)") unless
327
+ %w[http https].include?(uri.scheme) && !uri.host.to_s.empty? && uri.userinfo.nil?
328
+
329
+ valid_format(uri.normalize.to_s, "uri", field)
330
+ rescue Addressable::URI::InvalidURIError, ArgumentError
331
+ warning(field, "invalid URL")
332
+ end
333
+ end
334
+ end