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,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 <script> 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
|