Multi-resume profiles, ai-bw template, and README refresh
Resume data is loaded from resume/<slug>.yml via RESUME_NAME (default dobkin), with per-slug profile photos and webpack alias wiring. Export and preview honor the slug; package scripts add convenience dev/export targets. Add ai-bw layout and preview asset, cherepaha profile, and experience legend metadata on green and purple. When birth year is omitted, cool and material-dark themes show "Based in" instead of "Born" so birth.location can mean current location. README documents the new workflow and fixes export wording. Made-with: Cursor
This commit is contained in:
parent
770c24113b
commit
234a8866b1
13
README.md
13
README.md
@ -34,6 +34,9 @@
|
||||
<p>Green<br>
|
||||
<img src="src/assets/preview/resume-green.png" width="150" style="margin-right:5px; border: 1px solid #ccc;" />
|
||||
</p>
|
||||
<p>AI B&W<br>
|
||||
<img src="src/assets/preview/resume-ai-bw.png" width="150" style="margin-right:5px; border: 1px solid #ccc;" />
|
||||
</p>
|
||||
<p>Purple<br>
|
||||
<img src="src/assets/preview/resume-purple.png" width="150" style="margin-right:5px; border: 1px solid #ccc;" />
|
||||
</p>
|
||||
@ -85,16 +88,18 @@ git clone https://github.com/salomonelli/best-resume-ever.git
|
||||
|
||||
3. Run `npm install`. This may take a few seconds.
|
||||
|
||||
4. Customize your resume in the `resume/` directory: edit your data `data.yml` and replace the default profile-picture `id.jpg` with your picture. Rename your picture as `id.jpg` and copy it in the `resume/` directory. During this step, you may find it easier to navigate with Finder or File Explorer to get to the files. This will allow you to edit files with your computers default text editor.
|
||||
4. Customize your resume in the `resume/` directory. Data lives in one YAML file per profile, named `resume/<slug>.yml` (for example `resume/dobkin.yml`). The build defaults to the `dobkin` slug unless you set **`RESUME_NAME`** to another slug (without `.yml`). For a second profile, add `resume/cherepaha.yml` and run with `RESUME_NAME=cherepaha`. Convenience scripts are in `package.json` (for example `npm run dev:dobkin` and `npm run dev:cherepaha`).
|
||||
|
||||
5. Preview resumes with `npm run dev`. The command will start a server instance and listen on port 8080. Open (http://localhost:8080/home) in your browser. The page will show some resume previews. To see the preview of your resume, with your picture and data, click on one layout that you like and the resume will be opened in the same window.
|
||||
For the profile photo, use **`resume/<slug>.jpg`** when it matches the active slug, otherwise the fallback **`resume/id.jpg`** is used.
|
||||
|
||||
5. Preview resumes with `npm run dev` (or `RESUME_NAME=<slug> npm run dev`). The command will start a server instance and listen on port 8080. Open (http://localhost:8080/home) in your browser. The page will show some resume previews. To see the preview of your resume, with your picture and data, click on one layout that you like and the resume will be opened in the same window.
|
||||
|
||||

|
||||
|
||||
|
||||
6. Export your resume as pdf by running the command `npm run export`. In order to avoid errors due to the concurrency of two `npm run` commands, stop the execution of the previus `npm run dev` and then type the export command.
|
||||
6. Export your resume as PDF by running `npm run export` (with the same **`RESUME_NAME`** you used for dev, if not the default). To avoid errors from two `npm run` processes at once, stop any running `npm run dev` before exporting, unless you use a script that starts the dev server and export together (see `export:dobkin` and similar in `package.json`). You can also export a single template, for example `node scripts/export.js ai-bw`.
|
||||
|
||||
All resumes will be exported to the `pdf/` folder.
|
||||
PDFs are written to the `pdf/` folder.
|
||||
|
||||
<br>
|
||||
|
||||
|
||||
@ -1,12 +1,26 @@
|
||||
var path = require('path')
|
||||
var fs = require('fs')
|
||||
var utils = require('./utils')
|
||||
var config = require('../config')
|
||||
var vueLoaderConfig = require('./vue-loader.conf')
|
||||
var getResumeSlug = require('../scripts/resumeSlug').getResumeSlug
|
||||
|
||||
function resolve(dir) {
|
||||
return path.join(__dirname, '..', dir)
|
||||
}
|
||||
|
||||
var resumeDir = path.join(__dirname, '../resume')
|
||||
var resumeSlug = getResumeSlug()
|
||||
var resumeYmlPath = path.join(resumeDir, resumeSlug + '.yml')
|
||||
if (!fs.existsSync(resumeYmlPath)) {
|
||||
console.warn('[webpack] resume/' + resumeSlug + '.yml not found; using dobkin.yml')
|
||||
resumeSlug = 'dobkin'
|
||||
resumeYmlPath = path.join(resumeDir, 'dobkin.yml')
|
||||
}
|
||||
var profilePhotoPath = fs.existsSync(path.join(resumeDir, resumeSlug + '.jpg'))
|
||||
? path.join(resumeDir, resumeSlug + '.jpg')
|
||||
: path.join(resumeDir, 'id.jpg')
|
||||
|
||||
module.exports = {
|
||||
entry: {
|
||||
app: './src/main.js'
|
||||
@ -18,10 +32,12 @@ module.exports = {
|
||||
config.build.assetsPublicPath : config.dev.assetsPublicPath
|
||||
},
|
||||
resolve: {
|
||||
extensions: ['.js', '.vue', '.json'],
|
||||
extensions: ['.js', '.vue', '.json', '.yml'],
|
||||
alias: {
|
||||
'vue$': 'vue/dist/vue.esm.js',
|
||||
'@': resolve('src')
|
||||
'@': resolve('src'),
|
||||
'resume-person-data$': resumeYmlPath,
|
||||
'resume-profile-photo$': profilePhotoPath
|
||||
}
|
||||
},
|
||||
module: {
|
||||
@ -39,6 +55,11 @@ module.exports = {
|
||||
loader: 'vue-loader',
|
||||
options: vueLoaderConfig
|
||||
},
|
||||
{
|
||||
test: /\.yml$/,
|
||||
loader: 'babel-loader',
|
||||
include: [resumeDir]
|
||||
},
|
||||
{
|
||||
test: /\.js$/,
|
||||
loader: 'babel-loader',
|
||||
|
||||
@ -13,8 +13,11 @@
|
||||
"docs:serve": "cd docs/ && ws --port 8080 --rewrite '/best-resume-ever/* -> /$1'",
|
||||
"dev": "node --openssl-legacy-provider build/dev-server.js",
|
||||
"start": "node --openssl-legacy-provider build/dev-server.js",
|
||||
"dev:dobkin": "RESUME_NAME=dobkin node --openssl-legacy-provider build/dev-server.js",
|
||||
"dev:cherepaha": "RESUME_NAME=cherepaha node --openssl-legacy-provider build/dev-server.js",
|
||||
"pdf": "node scripts/export.js",
|
||||
"pdf:green": "node scripts/export.js green",
|
||||
"pdf:ai-bw": "node scripts/export.js ai-bw",
|
||||
"export:green": "concurrently \"npm run dev\" \"node scripts/export.js green\" --success first --kill-others --raw",
|
||||
"preview": "node scripts/preview.js",
|
||||
"test:deleteFiles": "node test/scripts/deleteFiles.js",
|
||||
@ -25,6 +28,11 @@
|
||||
"test:docs": "npm run docs && concurrently \"npm run docs:serve\" \"npm run test:cafe\" --success first --kill-others --raw",
|
||||
"test": "npm run test:export && npm run test:preview && npm run test:docs && npm run test:e2e",
|
||||
"export": "concurrently \"npm run dev\" \"npm run pdf\" --success first --kill-others --raw",
|
||||
"export:dobkin": "RESUME_NAME=dobkin concurrently \"npm run dev\" \"npm run pdf\" --success first --kill-others --raw",
|
||||
"export:dobkin:ai-bw": "RESUME_NAME=dobkin concurrently \"npm run dev\" \"node scripts/export.js ai-bw\" --success first --kill-others --raw",
|
||||
"export:cherepaha:ai-bw": "RESUME_NAME=cherepaha concurrently \"npm run dev\" \"node scripts/export.js ai-bw\" --success first --kill-others --raw",
|
||||
"export:bw": "node scripts/export-cli.js",
|
||||
"export:cherepaha": "RESUME_NAME=cherepaha concurrently \"npm run dev\" \"npm run pdf\" --success first --kill-others --raw",
|
||||
"lint": "eslint --ext .js,.vue src scripts",
|
||||
"lint:fix": "eslint --ext .js,.vue src scripts --fix"
|
||||
},
|
||||
|
||||
161
resume/cherepaha.yml
Normal file
161
resume/cherepaha.yml
Normal file
@ -0,0 +1,161 @@
|
||||
/* #*/ export const PERSON = `
|
||||
# experience: timeperiod = dates; location = separate line; description: | = one bullet per line.
|
||||
# remote: true | false | hybrid; employment: contract | full-time | co-op (icons on green/purple).
|
||||
# Build: RESUME_NAME=cherepaha npm run dev | PDF: RESUME_NAME=cherepaha npm run export
|
||||
|
||||
name:
|
||||
first: ILIYA
|
||||
middle:
|
||||
last: CHEREPAHA
|
||||
|
||||
about: "Senior QA Analyst with 15+ years testing large-scale insurance, banking, and Ontario public-sector systems across web, API, and integration layers. Proven track record leading SIT, UAT, and regression for Guidewire (PolicyCenter, BillingCenter, ClaimCenter), cloud migrations, and OPS enterprise platforms. Strengths in API testing, SQL/Oracle data validation, accessibility (AODA/WCAG 2.0 AA), and CI/CD-driven QA in Agile environments, with a focus on reducing escaped defects and keeping releases stable."
|
||||
|
||||
core_strengths: "Guidewire & P&C insurance QA; OPS/public-sector systems; API and data validation; accessibility compliance; collaborating with cross-functional teams to ship stable releases."
|
||||
|
||||
position: Senior QA Analyst
|
||||
|
||||
birth:
|
||||
year:
|
||||
location: Vaughan, ON
|
||||
|
||||
experience:
|
||||
- company: Amica Mutual Insurance
|
||||
position: Senior QA Analyst
|
||||
timeperiod: March 2023 - February 2026
|
||||
remote: true
|
||||
employment: contract
|
||||
location: Remote (USA)
|
||||
description: |
|
||||
• Led QA for cloud migration and ongoing releases of Guidewire v10 PolicyCenter, BillingCenter, and ClaimCenter on AWS/Azure, covering personal and commercial P&C insurance products.
|
||||
• Planned and executed smoke, functional, SIT, UAT, and regression cycles for multiple Guidewire and COTS releases annually, helping prevent major production incidents attributable to QA gaps.
|
||||
• Designed and executed end-to-end scenarios spanning UI, business rules, APIs, and cross-system data flows, increasing coverage of critical business paths and reducing late-cycle defects.
|
||||
• Executed and reviewed automated regression suites with Selenium and JMeter as part of CI/CD pipelines (Jenkins, Git), cutting manual regression effort per release and improving feedback speed.
|
||||
• Performed REST API and backend validation using Postman, SoapUI, SQL, and Oracle/SQL Server workflows, catching integration defects early and lowering UAT-reported issues.
|
||||
• Collaborated with developers, Salesforce CRM, and cloud teams to troubleshoot and resolve Guidewire–Salesforce CARE integration issues, reducing defect turnaround time.
|
||||
• Facilitated UAT with business stakeholders by preparing test data, tracking execution in Jira, and communicating daily status, helping keep releases on schedule.
|
||||
• Logged and tracked defects in Jira and maintained traceability of requirements and test assets using Git/GitHub, supporting clear audit trails.
|
||||
|
||||
- company: Ministry of Public & Business Service Delivery (Ontario Public Service)
|
||||
position: Senior QA Analyst (Contract)
|
||||
timeperiod: January 2021 - March 2023
|
||||
remote: hybrid
|
||||
employment: contract
|
||||
location: Toronto, ON
|
||||
description: |
|
||||
• Designed and maintained test strategies, plans, and detailed test cases for OPS provisioning systems across multiple integrated applications and services.
|
||||
• Performed manual and automated testing for GUI, non-GUI, API, and integrated COTS tools (including OIM), ensuring coverage across the full SDLC.
|
||||
• Developed and executed SQL queries for backend validation and Oracle 11g–12c migration testing, supporting accurate data transformations and reconciliations.
|
||||
• Used Azure DevOps for test case management, execution, traceability, reporting, and full defect lifecycle management in Agile teams.
|
||||
• Conducted API testing with Postman and SoapUI, and supported validation of security and service integrations between systems.
|
||||
• Ensured AODA/WCAG 2.0 AA accessibility compliance using tools such as JAWS, NVDA, Axe, and AChecker, identifying accessibility issues and supporting remediation.
|
||||
• Participated in defect triage and Agile ceremonies, coordinating with Developers, BAs, Security, Vendor, and UAT teams to ensure timely, quality delivery.
|
||||
|
||||
- company: LCBO
|
||||
position: Senior QA Analyst (Contract)
|
||||
timeperiod: May 2019 - November 2020
|
||||
remote: false
|
||||
employment: contract
|
||||
location: Toronto, ON
|
||||
description: |
|
||||
• Created and executed test strategies, plans, and test cases for the LCBO eCommerce platform, covering business, functional, technical, API, and integration requirements.
|
||||
• Conducted functional, system integration, regression, and end-to-end testing for a three-tier B2B application, validating flows across UI, services, and back-end systems.
|
||||
• Ran automated regression suites, reviewed results, and worked closely with developers to investigate failures, fix defects, and expand automated coverage over time.
|
||||
• Performed REST API testing using SoapUI and validated backend data integrity with SQL queries, supporting reliable order, inventory, and customer data flows.
|
||||
• Used HP ALM and Jira to manage test assets, track defects, and maintain test evidence to support timely resolution and audit requirements.
|
||||
• Collaborated with business and development teams to support deployment and validation of COTS modules and frequent system changes following structured SDLC/STLC practices.
|
||||
|
||||
- company: Greater Toronto Airports Authority (GTAA)
|
||||
position: Senior QA Analyst (Contract)
|
||||
timeperiod: December 2017 - March 2019
|
||||
remote: false
|
||||
employment: contract
|
||||
location: Toronto, ON
|
||||
description: |
|
||||
• Led QA efforts for the Resource Management System (RMS) supporting airport operations such as counters, gates, baggage, and planning, ensuring customizations and integrations met business needs.
|
||||
• Worked with business and architecture teams to translate requirements into testable technical specifications, including source-to-target mappings and traceability artifacts for RMS components.
|
||||
• Created and executed test strategies, plans, and traceability matrices for integrated RMS systems spanning multiple tiers and vendor-delivered components.
|
||||
• Performed functional, integration, and regression testing across three-tier applications, validating complex resource allocation and scheduling workflows.
|
||||
• Validated ETL workflows and data accuracy using SQL and Oracle for reconciliation and reporting, ensuring reliable operational and analytical data.
|
||||
• Tested REST APIs and business rules governing data flows, using TestRail and Jira for test management, defect tracking, and coordination with vendors and internal teams.
|
||||
|
||||
- company: Aviva Canada Inc.
|
||||
position: Senior QA Analyst (Contract)
|
||||
timeperiod: September 2014 - November 2017
|
||||
remote: false
|
||||
employment: contract
|
||||
location: Scarborough, ON
|
||||
description: |
|
||||
• Performed end-to-end testing for ClaimCenter, PolicyCenter, and BillingCenter, validating claims, policy, and billing integrations across P&C systems, including RBC Claim Conversion and regulatory reporting.
|
||||
• Designed and executed test strategies and scenarios for claims processing, financial integrations, and reporting, ensuring accurate flows to Oracle Financials for vendor and claimant payments.
|
||||
• Developed and executed SQL queries for backend validation and compliance checks, supporting regulatory and financial reporting accuracy.
|
||||
• Conducted functional, regression, and SIT testing for claims, policy, and billing workflows, focusing on cross-system dependencies and data consistency.
|
||||
• Performed API testing with Postman and SoapUI for SOAP and RESTful integrations between Guidewire and downstream systems.
|
||||
• Managed defects using JIRA and HP ALM, and executed AODA/WCAG 2.0 AA accessibility testing for customer-facing portals.
|
||||
|
||||
- company: Various (OPS Ministries, Banks, Insurance & Pharma)
|
||||
position: QA Analyst / Senior QA Analyst (Contract)
|
||||
timeperiod: 2000 - August 2014
|
||||
remote: false
|
||||
employment: contract
|
||||
location: Toronto, ON and GTA
|
||||
description: |
|
||||
• Delivered QA testing for large-scale financial, insurance, pharmaceutical, and public-sector systems at organizations including multiple OPS ministries (MGS, MCBS), TD Bank, RSA Insurance, Travelers Canada, Sun Life, Merrill Lynch–CIBC, Apotex, Clarica Life Insurance, and Intria/CIBC.
|
||||
• Performed functional, integration, and regression testing across web, client-server, and mainframe applications, focusing on system stability and data accuracy.
|
||||
• Contributed to early automation, API testing, and backend validation initiatives using SQL, Selenium, and Java, helping teams increase coverage and reduce manual effort.
|
||||
|
||||
education:
|
||||
- degree: B.Sc. Mechanical Engineering
|
||||
description: Minsk State Engineering College, Belarus
|
||||
timeperiod: 1990s
|
||||
|
||||
certifications:
|
||||
- name: API Test Automation – Udemy
|
||||
- name: Web Accessibility Testing – Udemy
|
||||
- name: Selenium WebDriver with Java and TestNG – Udemy (In progress)
|
||||
- name: Mobile Software Testing
|
||||
- name: Manual QA Engineer Essentials
|
||||
|
||||
skills:
|
||||
- name: "Testing: Test strategy, SIT, UAT, regression, end-to-end, smoke, exploratory"
|
||||
level: 94
|
||||
- name: "Domains: P&C insurance (Guidewire CC/PC/BC), banking, eCommerce, airport operations, OPS/public sector"
|
||||
level: 92
|
||||
- name: "API & web services: REST, SOAP, Postman, SoapUI, Swagger/OpenAPI, JSON, XML"
|
||||
level: 90
|
||||
- name: "Automation & performance: Selenium WebDriver, TestNG, JMeter, BlazeMeter, integrating with CI/CD"
|
||||
level: 86
|
||||
- name: "Test management & ALM: Azure DevOps, Jira, HP ALM, TestRail, Zephyr"
|
||||
level: 90
|
||||
- name: "Databases & data validation: SQL, PL/SQL, Oracle, SQL Server, TOAD, ETL workflows, DB2 (mainframe)"
|
||||
level: 90
|
||||
- name: "Middleware & integration: IBM WebSphere, IBM API Connect, Salesforce CRM integrations, mainframe batch"
|
||||
level: 82
|
||||
- name: "Accessibility: AODA/WCAG 2.0 AA, JAWS, NVDA, Axe, WAVE, AChecker"
|
||||
level: 88
|
||||
- name: "Cloud & platforms: Windows, Linux, AWS, Azure, SaaS and COTS applications, SharePoint"
|
||||
level: 84
|
||||
- name: "Scripting & languages: Java (test automation), Python (basics), JavaScript (basics), HTML, XML"
|
||||
level: 78
|
||||
|
||||
knowledge: []
|
||||
|
||||
projects: []
|
||||
|
||||
hobbies:
|
||||
- Baking and cooking
|
||||
- Home lab and self-hosted services
|
||||
- Learning new QA and automation tools
|
||||
|
||||
contributions: []
|
||||
|
||||
contact:
|
||||
email: iliya.cherepaha@rogers.com
|
||||
phone: +1 647-455-1151
|
||||
street:
|
||||
city: Maple, ON
|
||||
website: "https://www.linkedin.com/in/iliya-cherepaha-08649640/"
|
||||
website_label: LinkedIn
|
||||
github: ""
|
||||
github_label: ""
|
||||
lang: en
|
||||
`
|
||||
29
resume/data.yml → resume/dobkin.yml
Executable file → Normal file
29
resume/data.yml → resume/dobkin.yml
Executable file → Normal file
@ -7,17 +7,18 @@ name:
|
||||
first: ILIA
|
||||
middle:
|
||||
last: DOBKIN
|
||||
about: "Software Development Engineer in Test with 20+ years across audit/financial platforms and modern regulated web, including real-money iGaming. Experienced owning Playwright/Cypress/Selenium automation across UI, API, and performance, plus CI/CD pipelines and DevOps tooling. Known for test-pyramid and shift-left practices, stabilizing flaky suites, and improving pipeline reliability in high-availability environments. Proactively eliminates repetitive work across teams through scripting, scheduled jobs, and workflow automation—freeing colleagues to focus on higher-value tasks. Improved end-to-end regression cycle time by ~40% by expanding automated coverage and parallelizing suites in CI pipelines."
|
||||
about: "Senior Software Development Engineer in Test with 20+ years across audit/financial platforms and modern regulated web, including real-money iGaming. Experienced owning Playwright/Cypress/Selenium automation across UI, API, and performance, plus CI/CD pipelines and DevOps tooling. Known for test-pyramid and shift-left practices, stabilizing flaky suites, and improving pipeline reliability in high-availability environments. Proactively eliminates repetitive work across teams through scripting, scheduled jobs, and workflow automation—freeing colleagues to focus on higher-value tasks. Improved end-to-end regression cycle time by ~40% by expanding automated coverage and parallelizing suites in CI pipelines."
|
||||
core_strengths: ""
|
||||
position: Software Development Engineer in Test
|
||||
position: Senior Software Development Engineer in Test (SDET)
|
||||
|
||||
# Shown next to the map pin in the header (green / ai-bw). Other themes use it as a location line; leave year empty to show "Based in …" instead of "Born … in …".
|
||||
birth:
|
||||
year:
|
||||
location: Thornhill, Ontario, Canada
|
||||
location: Toronto, Ontario, Canada
|
||||
|
||||
experience:
|
||||
- company: Niyasoft Canada Inc.
|
||||
position: Quality Assurance Automation Engineer (SDET)
|
||||
position: Senior Quality Assurance Automation Engineer
|
||||
timeperiod: August 2023 - April 2026
|
||||
remote: true
|
||||
employment: full-time
|
||||
@ -43,7 +44,7 @@ experience:
|
||||
• Partnered with engineering and product on defect triage, risk-based prioritization, and pragmatic quality gates without blocking incremental delivery.
|
||||
|
||||
- company: Attabotics
|
||||
position: QA Automation Developer (SDET)
|
||||
position: QA Automation Developer
|
||||
timeperiod: September 2021 - May 2022
|
||||
remote: true
|
||||
employment: contract
|
||||
@ -54,7 +55,7 @@ experience:
|
||||
• Stood up Docker-based local and CI-aligned test environments; used SQL Server for data setup, assertions, and traceability across integrated warehouse-automation workflows.
|
||||
|
||||
- company: Levkin Inc.
|
||||
position: Senior Software Developer (SDET)
|
||||
position: Senior Software Developer
|
||||
timeperiod: October 2020 - August 2021
|
||||
remote: true
|
||||
employment: contract
|
||||
@ -78,7 +79,7 @@ experience:
|
||||
• Automated build and packaging workflows with scripting, compressing roughly eight hours of manual release effort down to under two minutes per cycle.
|
||||
|
||||
- company: MNP LLP
|
||||
position: Senior Application Developer
|
||||
position: Senior Application Developer 2
|
||||
timeperiod: August 2017 - June 2019
|
||||
remote: true
|
||||
employment: full-time
|
||||
@ -133,14 +134,16 @@ skills:
|
||||
level: 92
|
||||
- name: "Cloud & infra: AWS (Lambda, S3), Azure, GCP; Linux administration, Proxmox, Caddy, TrueNAS, Vaultwarden"
|
||||
level: 84
|
||||
- name: "Observability & performance: Grafana, Prometheus, Sentry, DataDog, Artillery, JMeter, metrics & logging"
|
||||
- name: "Observability & performance: Grafana, Prometheus, Sentry, DataDog, Artillery, k6, JMeter, metrics & logging"
|
||||
level: 86
|
||||
- name: "Data & domain: PostgreSQL, SQL Server, MySQL, DB2, Informatica/ETL; CaseWare/CaseView, audit & financial software"
|
||||
level: 78
|
||||
- name: "QA practices: BDD (SpecFlow, Cucumber, Gherkin), API testing (Postman, Swagger/OpenAPI), accessibility (AODA/WCAG), Agile/Scrum, Jira, shift-left QA"
|
||||
- name: "QA practices: BDD (SpecFlow, Cucumber, Gherkin), API testing (Postman, Swagger/OpenAPI), accessibility (AODA/WCAG), risk-based testing and prioritization, defect triage, quality gates, flaky-suite stabilization, test data and fixtures, Agile/Scrum, Jira, shift-left QA"
|
||||
level: 90
|
||||
- name: "AI & LLM testing: GenAI-assisted test design, prompt and workflow validation, privacy-first local LLM usage, MCP/agent-based automation"
|
||||
level: 80
|
||||
- name: "Leadership & collaboration: mentoring developers, test strategy and documentation, partnering with product and engineering on quality planning, release risk, and pragmatic gates without blocking delivery"
|
||||
level: 84
|
||||
- name: "AI & LLM tooling: AI-assisted engineering with Cursor, GitHub Copilot, Perplexity, Claude Code, ChatGPT, Windsurf, and Amazon Q Developer; GenAI-assisted test design and refactors; prompt and workflow validation; privacy-first local LLM usage; MCP servers and agent-based automation"
|
||||
level: 82
|
||||
|
||||
knowledge:
|
||||
|
||||
@ -155,6 +158,10 @@ projects:
|
||||
• Tool-using assistant wired into mail and calendars (triage, drafts, scheduling) on local GPU inference—prompts and context stay on-LAN, no SaaS LLMs.
|
||||
• Composable with homelab identity, TLS, secrets, and automation so event-driven workflows hand off without third-party model APIs; used daily for personal productivity.
|
||||
|
||||
- name: Levkin — Playwright MCP server
|
||||
description: |
|
||||
• Built an MCP server for developers to use from Cursor and other MCP-capable assistants while writing Playwright tests—surfacing selectors, fixtures, and in-repo conventions so generated specs stay aligned with team patterns.
|
||||
|
||||
hobbies: []
|
||||
|
||||
contributions: []
|
||||
60
scripts/export-cli.js
Normal file
60
scripts/export-cli.js
Normal file
@ -0,0 +1,60 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Export one resume YAML (resume/<slug>.yml) through one Vue template to pdf/<template>-<slug>.pdf
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/export-cli.js <resume-slug> [template]
|
||||
* npm run export:bw -- cherepaha
|
||||
* npm run export:bw -- dobkin green
|
||||
*
|
||||
* Default template: ai-bw
|
||||
*
|
||||
* Optional: PDF_SCALE=0.95 (0.1–2) slightly shrinks the PDF if you still need to squeeze to 2 pages.
|
||||
*/
|
||||
|
||||
const { spawnSync } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const repoRoot = path.join(__dirname, '..');
|
||||
|
||||
const slug = (process.argv[2] || '').trim().toLowerCase();
|
||||
const template = (process.argv[3] || 'ai-bw').trim().replace(/\.vue$/i, '').toLowerCase();
|
||||
|
||||
if (!slug) {
|
||||
console.error('Usage: node scripts/export-cli.js <resume-slug> [template]');
|
||||
console.error('');
|
||||
console.error('Examples:');
|
||||
console.error(' npm run export:bw -- cherepaha');
|
||||
console.error(' npm run export:bw -- dobkin');
|
||||
console.error(' node scripts/export-cli.js cherepaha green');
|
||||
console.error('');
|
||||
console.error('Resume data: resume/<slug>.yml Output: pdf/<template>-<slug>.pdf');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const yml = path.join(repoRoot, 'resume', slug + '.yml');
|
||||
if (!fs.existsSync(yml)) {
|
||||
console.error('Missing resume data file: ' + yml);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const vuePath = path.join(repoRoot, 'src', 'resumes', template + '.vue');
|
||||
if (!fs.existsSync(vuePath)) {
|
||||
console.error('Unknown template (no file): ' + vuePath);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const r = spawnSync(
|
||||
'npx',
|
||||
['concurrently', 'npm run dev', `node scripts/export.js ${template}`, '--success', 'first', '--kill-others', '--raw'],
|
||||
{
|
||||
cwd: repoRoot,
|
||||
env: Object.assign({}, process.env, { RESUME_NAME: slug }),
|
||||
stdio: 'inherit'
|
||||
}
|
||||
);
|
||||
|
||||
const code = typeof r.status === 'number' ? r.status : 1;
|
||||
process.exit(code);
|
||||
@ -1,8 +1,60 @@
|
||||
const puppeteer = require('puppeteer');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
function resolveChromeExecutable () {
|
||||
const fromEnv = (process.env.PUPPETEER_EXECUTABLE_PATH || process.env.CHROME_PATH || '').trim();
|
||||
if (fromEnv) {
|
||||
try {
|
||||
if (fs.existsSync(fromEnv)) return fromEnv;
|
||||
} catch (_e) {
|
||||
/* ignore */
|
||||
}
|
||||
console.warn(
|
||||
'[export] Ignoring PUPPETEER_EXECUTABLE_PATH / CHROME_PATH (not an existing file): ' + fromEnv
|
||||
);
|
||||
}
|
||||
const candidates = [];
|
||||
if (process.platform === 'darwin') {
|
||||
candidates.push(
|
||||
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
|
||||
'/Applications/Brave Browser.app/Contents/MacOS/Brave Browser',
|
||||
'/Applications/Arc.app/Contents/MacOS/Arc',
|
||||
'/Applications/Vivaldi.app/Contents/MacOS/Vivaldi',
|
||||
'/Applications/Chromium.app/Contents/MacOS/Chromium'
|
||||
);
|
||||
} else if (process.platform === 'linux') {
|
||||
candidates.push(
|
||||
'/usr/bin/google-chrome-stable',
|
||||
'/usr/bin/google-chrome',
|
||||
'/usr/bin/chromium-browser',
|
||||
'/usr/bin/chromium',
|
||||
'/usr/bin/microsoft-edge-stable',
|
||||
'/snap/bin/chromium'
|
||||
);
|
||||
} else if (process.platform === 'win32') {
|
||||
const programFiles = process.env['PROGRAMFILES'] || 'C:\\Program Files';
|
||||
const programFilesX86 = process.env['PROGRAMFILES(X86)'] || 'C:\\Program Files (x86)';
|
||||
candidates.push(
|
||||
path.join(programFiles, 'Google/Chrome/Application/chrome.exe'),
|
||||
path.join(programFilesX86, 'Google/Chrome/Application/chrome.exe'),
|
||||
path.join(programFiles, 'Microsoft/Edge/Application/msedge.exe')
|
||||
);
|
||||
}
|
||||
for (const p of candidates) {
|
||||
try {
|
||||
if (p && fs.existsSync(p)) return p;
|
||||
} catch (_e) {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
const http = require('http');
|
||||
const config = require('../config');
|
||||
const { getResumeSlug } = require('./resumeSlug');
|
||||
|
||||
const devPort = process.env.PORT || config.dev.port;
|
||||
|
||||
@ -39,6 +91,67 @@ const waitForServerReachable = () => {
|
||||
filter(ok => !!ok)
|
||||
);
|
||||
};
|
||||
|
||||
const baseLaunchOpts = () => ({
|
||||
headless: true,
|
||||
args: ['--no-sandbox', '--font-render-hinting=none']
|
||||
});
|
||||
|
||||
/**
|
||||
* PDF export needs a headless Chromium engine to run the Vue app and call page.pdf().
|
||||
* Try several launch strategies so a typical Mac (Edge-only, Arc-only, etc.) still works.
|
||||
*/
|
||||
async function launchPuppeteerBrowser () {
|
||||
const base = baseLaunchOpts();
|
||||
const attempts = [];
|
||||
|
||||
const resolved = resolveChromeExecutable();
|
||||
if (resolved) {
|
||||
attempts.push({ ...base, executablePath: resolved });
|
||||
}
|
||||
|
||||
const preferredChannel = (process.env.PUPPETEER_CHANNEL || '').trim();
|
||||
const channelOrder = preferredChannel
|
||||
? [preferredChannel]
|
||||
: ['msedge', 'chrome', 'chrome-beta', 'chrome-canary'];
|
||||
|
||||
for (const ch of channelOrder) {
|
||||
attempts.push({ ...base, channel: ch });
|
||||
}
|
||||
|
||||
let bundled = '';
|
||||
try {
|
||||
if (typeof puppeteer.executablePath === 'function') {
|
||||
bundled = puppeteer.executablePath();
|
||||
}
|
||||
} catch (_e) {
|
||||
bundled = '';
|
||||
}
|
||||
if (bundled && fs.existsSync(bundled) && bundled !== resolved) {
|
||||
attempts.push({ ...base, executablePath: bundled });
|
||||
}
|
||||
|
||||
attempts.push({ ...base });
|
||||
|
||||
const errors = [];
|
||||
for (const opts of attempts) {
|
||||
try {
|
||||
const browser = await puppeteer.launch(opts);
|
||||
const how = opts.executablePath
|
||||
? ('executablePath: ' + opts.executablePath)
|
||||
: (opts.channel ? ('channel: ' + opts.channel) : 'puppeteer default');
|
||||
console.log('[export] Using browser — ' + how);
|
||||
return browser;
|
||||
} catch (e) {
|
||||
errors.push(e && e.message ? e.message : String(e));
|
||||
}
|
||||
}
|
||||
console.error('[export] All browser launch attempts failed:\n - ' + errors.join('\n - '));
|
||||
throw new Error(
|
||||
'No Chromium-based browser available for PDF export. Install Google Chrome or Microsoft Edge, ' +
|
||||
'or run: npx puppeteer browsers install chrome'
|
||||
);
|
||||
}
|
||||
/*
|
||||
const timedOut = timeout => {
|
||||
return new Promise(res => {
|
||||
@ -55,6 +168,7 @@ const convert = async () => {
|
||||
console.log('Exporting ...');
|
||||
try {
|
||||
const fullDirectoryPath = path.join(__dirname, '../pdf/');
|
||||
const dataSlug = getResumeSlug();
|
||||
let directories = getResumesFromDirectories();
|
||||
const resumeFilterRaw = (process.env.EXPORT_RESUME || process.argv[2] || '').trim();
|
||||
const resumeFilter = resumeFilterRaw.replace(/\.vue$/i, '').toLowerCase();
|
||||
@ -68,12 +182,17 @@ const convert = async () => {
|
||||
}
|
||||
console.log('Resume filter: ' + directories.map(d => d.name).join(', '));
|
||||
}
|
||||
console.log('Resume data (RESUME_NAME): ' + dataSlug);
|
||||
|
||||
if (!fs.existsSync(fullDirectoryPath)) {
|
||||
fs.mkdirSync(fullDirectoryPath);
|
||||
}
|
||||
|
||||
const browser = await launchPuppeteerBrowser();
|
||||
try {
|
||||
for (const dir of directories) {
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
args: ['--no-sandbox', '--font-render-hinting=none']
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
try {
|
||||
await page.goto(`http://localhost:${devPort}/#/resume/` + dir.name, {
|
||||
waitUntil: 'load',
|
||||
timeout: 120000
|
||||
@ -86,21 +205,31 @@ const convert = async () => {
|
||||
await page.emulateMediaType('print');
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
|
||||
if (
|
||||
!fs.existsSync(fullDirectoryPath)
|
||||
) {
|
||||
fs.mkdirSync(fullDirectoryPath);
|
||||
let pdfScale = 1;
|
||||
const rawScale = process.env.PDF_SCALE;
|
||||
if (rawScale !== undefined && String(rawScale).trim() !== '') {
|
||||
const n = parseFloat(String(rawScale), 10);
|
||||
if (Number.isFinite(n) && n >= 0.1 && n <= 2) {
|
||||
pdfScale = n;
|
||||
}
|
||||
}
|
||||
|
||||
await page.pdf({
|
||||
path: fullDirectoryPath + dir.name + '.pdf',
|
||||
path: fullDirectoryPath + dir.name + '-' + dataSlug + '.pdf',
|
||||
format: 'A4',
|
||||
printBackground: true,
|
||||
margin: { top: '0', bottom: '0', left: '0', right: '0' }
|
||||
margin: { top: '0', bottom: '0', left: '0', right: '0' },
|
||||
scale: pdfScale
|
||||
});
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
} catch (err) {
|
||||
throw new Error(err);
|
||||
throw err instanceof Error ? err : new Error(String(err));
|
||||
}
|
||||
console.log('Finished exports.');
|
||||
};
|
||||
|
||||
15
scripts/resumeSlug.js
Normal file
15
scripts/resumeSlug.js
Normal file
@ -0,0 +1,15 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Sanitized slug for resume data file (resume/<slug>.yml) and PDF/PNG naming.
|
||||
* Set RESUME_NAME when building, exporting, or previewing (default: dobkin).
|
||||
*/
|
||||
function getResumeSlug () {
|
||||
const s = String(process.env.RESUME_NAME || 'dobkin')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_-]+/g, '');
|
||||
return s || 'dobkin';
|
||||
}
|
||||
|
||||
module.exports = { getResumeSlug };
|
||||
@ -23,7 +23,7 @@ export default {
|
||||
body {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
overflow-x: hidden;
|
||||
overflow-x: auto;
|
||||
background: white;
|
||||
}
|
||||
</style>
|
||||
|
||||
BIN
src/assets/preview/resume-ai-bw.png
Normal file
BIN
src/assets/preview/resume-ai-bw.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
@ -3,9 +3,11 @@ const lang = {
|
||||
contact: 'Contact',
|
||||
born: 'Born',
|
||||
bornIn: 'in',
|
||||
basedIn: 'Based in',
|
||||
experience: 'Experience',
|
||||
experienceLegendIntro: 'Key',
|
||||
education: 'Education',
|
||||
certifications: 'Certifications & training',
|
||||
skills: 'Skills',
|
||||
projects: 'Projects',
|
||||
contributions: 'Contributions',
|
||||
|
||||
@ -148,6 +148,14 @@
|
||||
</div>
|
||||
</router-link>
|
||||
</div>
|
||||
<div class="preview">
|
||||
<router-link v-bind:to="'/resume/ai-bw'">
|
||||
<div class="preview-wrapper">
|
||||
<img src="../assets/preview/resume-ai-bw.png" />
|
||||
<span>ai-bw</span>
|
||||
</div>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sponsoring">
|
||||
|
||||
@ -18,11 +18,13 @@ export default Vue.component('resume', {
|
||||
|
||||
<style scoped>
|
||||
.page-inner {
|
||||
min-height: 100%;
|
||||
min-height: auto;
|
||||
width: 100%;
|
||||
}
|
||||
.page-wrapper {
|
||||
overflow-x: hidden;
|
||||
/* Do not clip: fixed A4 width + flex rows must not truncate dates/locations */
|
||||
overflow-x: visible;
|
||||
overflow-y: visible;
|
||||
background: white;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
709
src/resumes/ai-bw.vue
Normal file
709
src/resumes/ai-bw.vue
Normal file
@ -0,0 +1,709 @@
|
||||
<template>
|
||||
<div class="resume" id="template">
|
||||
<div id="resume-header">
|
||||
<div id="header-left">
|
||||
<h2 id="position">{{person.position}}</h2>
|
||||
<h1 id="name">{{person.name.first}} {{person.name.last}}</h1>
|
||||
<div id="info-flex">
|
||||
<span id="email"><a :href='"mailto:" + person.contact.email'>
|
||||
<i class="fa fa-envelope" aria-hidden="true"></i>{{ person.contact.email }}</a></span>
|
||||
<span id="phone"><i class="fa fa-phone-square" aria-hidden="true"></i> {{person.contact.phone}}</span>
|
||||
<span v-if="person.contact.website" id="website"><a :href="contactLinks.website"><i class="fa fa-linkedin" aria-hidden="true"></i>{{ person.contact.website_label || person.contact.website }}</a></span>
|
||||
<span v-if="person.contact.github" id="github"><a :href="contactLinks.github"><i class="fa fa-code-fork" aria-hidden="true"></i>{{ person.contact.github_label || 'Git' }}</a></span>
|
||||
<span v-if="person.contact.github_profile && contactLinks.github_profile" id="github-profile"><a :href="contactLinks.github_profile"><i class="fa fa-github" aria-hidden="true"></i>{{ person.contact.github_profile_label || ('@' + String(person.contact.github_profile).replace(/^@/, '')) }}</a></span>
|
||||
<span v-if="person.contact.personal_site" id="personal-site"><a :href="contactLinks.personal_site"><i class="fa fa-globe" aria-hidden="true"></i>{{ person.contact.personal_site_label || person.contact.personal_site }}</a></span>
|
||||
<span v-if="person.birth && person.birth.location" id="location"><i class="fa fa-map-marker" aria-hidden="true"></i> {{person.birth.location}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="header-right">
|
||||
<div id="headshot"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="resume-about" v-if="person.about">
|
||||
<h2>{{ lang.about }}</h2>
|
||||
<p>{{person.about}}</p>
|
||||
<p v-if="person.core_strengths" class="core-strengths"><span class="core-strengths-label">Core strengths:</span> {{person.core_strengths}}</p>
|
||||
</div>
|
||||
<div id="resume-body">
|
||||
<div id="experience-container">
|
||||
<h2 id="experience-title">{{ lang.experience }}</h2>
|
||||
<p
|
||||
v-if="person.experience && person.experience.length"
|
||||
class="experience-legend"
|
||||
role="note">
|
||||
<span class="experience-legend-intro">{{ lang.experienceLegendIntro }}:</span>
|
||||
<span
|
||||
v-for="item in workArrangementLegendItems()"
|
||||
:key="item.key"
|
||||
class="experience-legend-item">
|
||||
<i :class="item.icon" aria-hidden="true"></i>
|
||||
<span class="experience-legend-text">{{ item.label }}</span>
|
||||
</span>
|
||||
</p>
|
||||
<div class="spacer"></div>
|
||||
<div class="experience" v-for="experience in person.experience" :key="experience.company">
|
||||
<h2 class="company-row">
|
||||
<span class="company-primary">
|
||||
<span :class="['company', experience.sub_companies && 'company-section-label']">{{experience.company}}</span><!--
|
||||
--><span
|
||||
v-if="experienceWorkBadges(experience).length"
|
||||
class="exp-name-icon-gutter"
|
||||
aria-hidden="true"></span><!--
|
||||
--><span
|
||||
v-if="experienceWorkBadges(experience).length"
|
||||
class="experience-icons"
|
||||
aria-hidden="true">
|
||||
<span
|
||||
v-for="b in experienceWorkBadges(experience)"
|
||||
:key="b.key"
|
||||
class="exp-icon-cell">
|
||||
<i
|
||||
:class="b.icon"
|
||||
:title="b.label"></i>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<span v-if="experienceLocation(experience)" class="company-location">{{ experienceLocation(experience) }}</span>
|
||||
</h2>
|
||||
<p class="job-info" v-if="!experience.sub_companies">
|
||||
<span class="job-title">{{experience.position}}</span>
|
||||
<span class="experience-timeperiod">{{ experienceDateRange(experience) }}</span>
|
||||
</p>
|
||||
<p class="job-info sub-companies-line" v-if="experience.sub_companies && experience.sub_companies.length">
|
||||
<span class="sub-companies-list">
|
||||
<span
|
||||
v-for="(sub, si) in experience.sub_companies"
|
||||
:key="si"
|
||||
class="sub-company-entry">
|
||||
<span class="sub-company-name">{{ sub.name }}</span><!--
|
||||
--><span
|
||||
v-if="experienceWorkBadges(sub).length"
|
||||
class="sub-icon-gutter"
|
||||
aria-hidden="true"></span><!--
|
||||
--><span
|
||||
v-for="b in experienceWorkBadges(sub)"
|
||||
:key="b.key"
|
||||
class="sub-icon-cell"
|
||||
aria-hidden="true"><i
|
||||
:class="b.icon"
|
||||
:title="b.label"></i></span>
|
||||
<span
|
||||
v-if="si < experience.sub_companies.length - 1"
|
||||
class="sub-company-sep"
|
||||
aria-hidden="true">·</span>
|
||||
</span>
|
||||
</span>
|
||||
<span class="experience-timeperiod">{{ experienceDateRange(experience) }}</span>
|
||||
</p>
|
||||
<ul
|
||||
v-if="experienceDescriptionLines(experience).length"
|
||||
class="job-description-list">
|
||||
<li
|
||||
v-for="(line, idx) in experienceDescriptionLines(experience)"
|
||||
:key="idx"
|
||||
class="list-item-black">{{ stripLeadingBullet(line) }}</li>
|
||||
</ul>
|
||||
<ul v-if="experience.list" >
|
||||
<li v-for="(item, index) in experience.list" :key="index">
|
||||
<span class="list-item-black">
|
||||
{{item}}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div id="projects-container" v-if="person.projects && person.projects.length">
|
||||
<h2 id="projects-title">{{ lang.projects }}</h2>
|
||||
<div class="spacer"></div>
|
||||
<div class="project" v-for="project in person.projects" :key="project.name">
|
||||
<h2 class="project-name">{{ project.name }}</h2>
|
||||
<ul
|
||||
v-if="experienceDescriptionLines(project).length"
|
||||
class="job-description-list">
|
||||
<li
|
||||
v-for="(line, idx) in experienceDescriptionLines(project)"
|
||||
:key="idx"
|
||||
class="list-item-black">{{ stripLeadingBullet(line) }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div id="education-container" v-if="person.education && person.education.length">
|
||||
<h2 id="education-title">{{ lang.education }}</h2>
|
||||
<div class="spacer"></div>
|
||||
<div class="education" v-for="education in person.education" :key="education.degree">
|
||||
<h2 class="education-description">{{education.description}}</h2>
|
||||
<p><span class="degree">{{education.degree}} | </span><span class="education-timeperiod">{{education.timeperiod}}</span></p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="certifications-container" v-if="person.certifications && person.certifications.length">
|
||||
<h2 id="certifications-title">{{ lang.certifications }}</h2>
|
||||
<div class="spacer"></div>
|
||||
<ul class="certifications-list">
|
||||
<li
|
||||
v-for="(c, cidx) in person.certifications"
|
||||
:key="cidx"
|
||||
class="list-item-black">{{ certificationLabel(c) }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div id="skills-container" v-if="person.skills && person.skills.length">
|
||||
<h2 id="skills-title">{{ lang.skills }}</h2>
|
||||
<div class="spacer"></div>
|
||||
<p
|
||||
v-if="person.knowledge && String(person.knowledge).trim()"
|
||||
id="skill-description">{{ person.knowledge }}</p>
|
||||
<div id="skill-grid">
|
||||
<div
|
||||
v-for="skill in person.skills"
|
||||
:key="skill.name"
|
||||
class="skill-cell">{{ skill.name }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Vue from 'vue';
|
||||
import { getVueOptions } from './options';
|
||||
|
||||
const name = 'ai-bw';
|
||||
const baseOptions = getVueOptions(name);
|
||||
export default Vue.component(name, Object.assign({}, baseOptions, {
|
||||
methods: Object.assign({}, baseOptions.methods || {}, {
|
||||
experienceLocation (exp) {
|
||||
const loc = exp && exp.location;
|
||||
return (loc !== undefined && loc !== null && String(loc).trim()) ? String(loc).trim() : '';
|
||||
},
|
||||
experienceDateRange (exp) {
|
||||
const t = exp && exp.timeperiod;
|
||||
return (t !== undefined && t !== null && String(t).trim()) ? String(t).trim() : '';
|
||||
},
|
||||
experienceDescriptionLines (exp) {
|
||||
if (!exp || exp.description === undefined || exp.description === null) return [];
|
||||
const raw = String(exp.description).trim();
|
||||
if (!raw) return [];
|
||||
let lines = raw.split(/\r?\n/).map(l => l.trim()).filter(Boolean);
|
||||
if (lines.length === 1 && lines[0].indexOf('•') !== -1) {
|
||||
const parts = lines[0].split(/\s•\s/).map(p => p.trim()).filter(Boolean);
|
||||
if (parts.length > 1) {
|
||||
lines = parts.map((p) => (p.charAt(0) === '•' ? p : '• ' + p));
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
},
|
||||
stripLeadingBullet (line) {
|
||||
return String(line).replace(/^[•*-]\s*/, '').trim();
|
||||
},
|
||||
certificationLabel (c) {
|
||||
if (c === undefined || c === null) return '';
|
||||
if (typeof c === 'string') return String(c).trim();
|
||||
const n = c.name;
|
||||
return (n !== undefined && n !== null && String(n).trim()) ? String(n).trim() : '';
|
||||
}
|
||||
})
|
||||
}));
|
||||
</script>
|
||||
|
||||
<!-- Black/white layout with Font Awesome icons (header + experience badges). -->
|
||||
<style lang="less" scoped>
|
||||
@ink: #111;
|
||||
@muted: #444;
|
||||
@pad-x: 24px;
|
||||
#template {
|
||||
box-sizing: border-box;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
overflow-x: visible;
|
||||
overflow-y: visible;
|
||||
background: #fff;
|
||||
color: @ink;
|
||||
|
||||
h1, h2 {
|
||||
margin: 0;
|
||||
color: @ink;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
ul li {
|
||||
color: @ink;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: @ink;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.list-item-black {
|
||||
color: @ink;
|
||||
}
|
||||
|
||||
#resume-header {
|
||||
color: @ink;
|
||||
background: #fff;
|
||||
border-bottom: 2px solid @ink;
|
||||
padding: 14px @pad-x 10px;
|
||||
|
||||
#header-left {
|
||||
width: 100%;
|
||||
float: left;
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
color: @ink;
|
||||
text-transform: none;
|
||||
font-weight: 700;
|
||||
line-height: 1.15;
|
||||
}
|
||||
h2 {
|
||||
font-size: 13px;
|
||||
color: @muted;
|
||||
font-weight: 400;
|
||||
}
|
||||
#info-flex {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 8px;
|
||||
font-size: 10px;
|
||||
gap: 4px 12px;
|
||||
|
||||
span {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
i {
|
||||
color: @ink;
|
||||
font-size: 12px;
|
||||
margin-right: 5px;
|
||||
vertical-align: middle;
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#resume-about {
|
||||
flex-shrink: 0;
|
||||
padding: 6px @pad-x 8px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #ccc;
|
||||
box-sizing: border-box;
|
||||
h2 {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
margin-bottom: 4px;
|
||||
font-weight: 700;
|
||||
}
|
||||
h2, p {
|
||||
color: @ink;
|
||||
}
|
||||
p {
|
||||
font-size: 10px;
|
||||
line-height: 1.38;
|
||||
margin: 0;
|
||||
white-space: pre-line;
|
||||
}
|
||||
.core-strengths {
|
||||
margin-top: 4px;
|
||||
font-size: 10px;
|
||||
line-height: 1.38;
|
||||
white-space: normal;
|
||||
}
|
||||
.core-strengths-label {
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
#resume-body {
|
||||
/* Natural height only — flex-grow was stretching the column and confused print/preview */
|
||||
flex: 0 0 auto;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 8px @pad-x 8px;
|
||||
background: #fff;
|
||||
|
||||
#experience-title, #education-title, #certifications-title, #skills-title, #projects-title {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
#experience-container {
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
box-sizing: border-box;
|
||||
margin-bottom: 6px;
|
||||
#experience-title {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.experience-legend {
|
||||
display: block;
|
||||
margin: 0 0 2px 0;
|
||||
padding: 0;
|
||||
font-size: 9px;
|
||||
line-height: 1.4;
|
||||
color: @muted;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.experience-legend-intro {
|
||||
display: inline-block;
|
||||
font-weight: 700;
|
||||
color: @ink;
|
||||
margin-right: 8px;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
.experience-legend-item {
|
||||
display: inline-block;
|
||||
margin-right: 14px;
|
||||
margin-bottom: 2px;
|
||||
white-space: nowrap;
|
||||
vertical-align: baseline;
|
||||
&:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
i {
|
||||
display: inline-block;
|
||||
color: @ink;
|
||||
font-size: 11px;
|
||||
width: 1.25em;
|
||||
min-width: 1.25em;
|
||||
margin-right: 5px;
|
||||
text-align: center;
|
||||
vertical-align: baseline;
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
.experience-legend-text {
|
||||
display: inline;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.experience {
|
||||
margin: 0 0 9px 0;
|
||||
break-inside: avoid;
|
||||
page-break-inside: avoid;
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
ul {
|
||||
margin: 3px 0 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Grid keeps location in the right column; flex-wrap was sending it to a full-width row under bullets */
|
||||
.company-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(min-content, max-content);
|
||||
column-gap: 10px;
|
||||
align-items: baseline;
|
||||
margin: 0 0 5px 0;
|
||||
line-height: 1.25;
|
||||
.company-primary {
|
||||
min-width: 0;
|
||||
display: block;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
.company {
|
||||
display: inline;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: @ink;
|
||||
}
|
||||
.exp-name-icon-gutter {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
min-width: 8px;
|
||||
height: 1px;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
.experience-icons {
|
||||
display: inline;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
.exp-icon-cell {
|
||||
display: inline-block;
|
||||
margin-right: 7px;
|
||||
vertical-align: baseline;
|
||||
&:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
i {
|
||||
color: @ink;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
vertical-align: baseline;
|
||||
opacity: 0.88;
|
||||
}
|
||||
}
|
||||
.company-location {
|
||||
justify-self: end;
|
||||
align-self: baseline;
|
||||
font-size: 10px;
|
||||
font-weight: 400;
|
||||
color: @muted;
|
||||
line-height: 1.25;
|
||||
text-align: right;
|
||||
white-space: normal;
|
||||
overflow-wrap: break-word;
|
||||
word-break: normal;
|
||||
}
|
||||
}
|
||||
|
||||
.education-description {
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.job-info {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(min-content, max-content);
|
||||
column-gap: 10px;
|
||||
align-items: baseline;
|
||||
margin: 0 0 5px 0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.job-description-list {
|
||||
margin: 0;
|
||||
padding-left: 1.1em;
|
||||
list-style-position: outside;
|
||||
list-style-type: disc;
|
||||
li {
|
||||
font-size: 10px;
|
||||
line-height: 1.32;
|
||||
margin: 0 0 1px 0;
|
||||
padding-left: 1px;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
li::marker {
|
||||
color: @ink;
|
||||
}
|
||||
}
|
||||
|
||||
.job-title, .degree {
|
||||
font-weight: 700;
|
||||
color: @ink;
|
||||
font-size: 11px;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.experience-timeperiod {
|
||||
justify-self: end;
|
||||
align-self: baseline;
|
||||
font-weight: 400;
|
||||
color: @muted;
|
||||
font-size: 10px;
|
||||
line-height: 1.3;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sub-companies-list {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.sub-company-entry {
|
||||
display: inline;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.sub-company-name {
|
||||
font-weight: 700;
|
||||
color: @ink;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.sub-icon-gutter {
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.sub-icon-cell {
|
||||
display: inline-block;
|
||||
margin-right: 5px;
|
||||
i {
|
||||
color: @ink;
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
vertical-align: baseline;
|
||||
opacity: 0.88;
|
||||
}
|
||||
}
|
||||
|
||||
.sub-company-sep {
|
||||
display: inline-block;
|
||||
margin: 0 6px;
|
||||
color: @muted;
|
||||
font-weight: 700;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.education-timeperiod {
|
||||
font-weight: 400;
|
||||
color: @muted;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.education {
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
#skills-container {
|
||||
margin-top: 14px;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
box-sizing: border-box;
|
||||
#skills-title {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
#skill-description {
|
||||
font-size: 9px;
|
||||
line-height: 1.3;
|
||||
color: @muted;
|
||||
margin: 0 0 2px 0;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
column-count: 1;
|
||||
}
|
||||
|
||||
#skill-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
column-gap: 0;
|
||||
row-gap: 3px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.skill-cell {
|
||||
font-size: 9px;
|
||||
line-height: 1.28;
|
||||
color: @ink;
|
||||
min-width: 0;
|
||||
padding: 2px 0;
|
||||
background: transparent;
|
||||
border-left: none;
|
||||
border-bottom: 1px solid #ddd;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
#projects-container {
|
||||
margin-top: 14px;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
box-sizing: border-box;
|
||||
#projects-title {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.project {
|
||||
margin: 0 0 4px 0;
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.company-section-label {
|
||||
font-style: italic;
|
||||
font-weight: 600;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: @muted;
|
||||
}
|
||||
|
||||
.project-name {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: @ink;
|
||||
margin: 0 0 2px 0;
|
||||
}
|
||||
|
||||
#education-container {
|
||||
margin-top: 14px;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
box-sizing: border-box;
|
||||
#education-title {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
#certifications-container {
|
||||
margin-top: 14px;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
box-sizing: border-box;
|
||||
#certifications-title {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.certifications-list {
|
||||
margin: 0;
|
||||
padding-left: 1.2em;
|
||||
list-style-position: outside;
|
||||
list-style-type: disc;
|
||||
li {
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
margin: 0 0 2px 0;
|
||||
padding-left: 2px;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
li::marker {
|
||||
color: @ink;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@page {
|
||||
margin: 4mm 5mm 5mm 5mm;
|
||||
}
|
||||
|
||||
@page :first {
|
||||
margin-top: 0;
|
||||
margin-bottom: 4mm;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
width: 100%;
|
||||
border-bottom: 1px solid #999;
|
||||
margin: 4px 0 8px;
|
||||
padding-top: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
</style>
|
||||
@ -3,7 +3,10 @@
|
||||
<div class="banner">
|
||||
<div class="banner__fullname">{{ person.name.first }} {{ person.name.middle }} {{ person.name.last }}</div>
|
||||
<div class="banner__position">{{ person.position }}</div>
|
||||
<div class="banner__location">{{ lang.born }} {{person.birth.year}} {{ lang.bornIn }} {{person.birth.location}}</div>
|
||||
<div v-if="person.birth && person.birth.location" class="banner__location">
|
||||
<template v-if="person.birth.year">{{ lang.born }} {{ person.birth.year }} {{ lang.bornIn }} {{ person.birth.location }}</template>
|
||||
<template v-else>{{ lang.basedIn }} {{ person.birth.location }}</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
@ -224,7 +227,7 @@ a {
|
||||
width: @picture-size;
|
||||
border-radius: 50%;
|
||||
border: 5px solid @accent-color;
|
||||
content: url('../../resume/id.jpg');
|
||||
content: url('~resume-profile-photo');
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
|
||||
@ -6,8 +6,12 @@
|
||||
>{{ person.name.first }} {{ person.name.middle }} {{ person.name.last }}</div>
|
||||
<div class="banner__position">{{ person.position }}</div>
|
||||
<div
|
||||
v-if="person.birth && person.birth.location"
|
||||
class="banner__location"
|
||||
>{{ lang.born }} {{person.birth.year}} {{ lang.bornIn }} {{person.birth.location}}</div>
|
||||
>
|
||||
<template v-if="person.birth.year">{{ lang.born }} {{ person.birth.year }} {{ lang.bornIn }} {{ person.birth.location }}</template>
|
||||
<template v-else>{{ lang.basedIn }} {{ person.birth.location }}</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
@ -218,7 +222,7 @@ export default Vue.component(name, getVueOptions(name));
|
||||
width: @picture-size;
|
||||
border-radius: 50%;
|
||||
border: 5px solid @accent-color;
|
||||
content: url('../../resume/id.jpg');
|
||||
content: url('~resume-profile-photo');
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
|
||||
@ -3,7 +3,10 @@
|
||||
<div class="banner">
|
||||
<div class="banner__fullname">{{ person.name.first }} {{ person.name.middle }} {{ person.name.last }}</div>
|
||||
<div class="banner__position">{{ person.position }}</div>
|
||||
<div v-if="person.birth" class="banner__location">{{ lang.born }} {{person.birth.year}} {{ lang.bornIn }} {{person.birth.location}}</div>
|
||||
<div v-if="person.birth && person.birth.location" class="banner__location">
|
||||
<template v-if="person.birth.year">{{ lang.born }} {{ person.birth.year }} {{ lang.bornIn }} {{ person.birth.location }}</template>
|
||||
<template v-else>{{ lang.basedIn }} {{ person.birth.location }}</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
@ -230,7 +233,7 @@ export default Vue.component(name, getVueOptions(name));
|
||||
width: @picture-size;
|
||||
border-radius: 50%;
|
||||
border: 5px solid @accent-color;
|
||||
content: url('../../resume/id.jpg');
|
||||
content: url('~resume-profile-photo');
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
|
||||
@ -135,6 +135,16 @@
|
||||
<p><span class="degree">{{education.degree}} | </span><span class="education-timeperiod">{{education.timeperiod}}</span></p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="certifications-container" v-if="person.certifications && person.certifications.length">
|
||||
<h2 id="certifications-title">{{ lang.certifications }}</h2>
|
||||
<div class="spacer"></div>
|
||||
<ul class="certifications-list">
|
||||
<li
|
||||
v-for="(c, cidx) in person.certifications"
|
||||
:key="cidx"
|
||||
class="list-item-black">{{ certificationLabel(c) }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div id="skills-container" v-if="person.skills && person.skills.length">
|
||||
<h2 id="skills-title">{{ lang.skills }}</h2>
|
||||
<div class="spacer"></div>
|
||||
@ -183,6 +193,12 @@ export default Vue.component(name, Object.assign({}, baseOptions, {
|
||||
},
|
||||
stripLeadingBullet (line) {
|
||||
return String(line).replace(/^[•*-]\s*/, '').trim();
|
||||
},
|
||||
certificationLabel (c) {
|
||||
if (c === undefined || c === null) return '';
|
||||
if (typeof c === 'string') return String(c).trim();
|
||||
const n = c.name;
|
||||
return (n !== undefined && n !== null && String(n).trim()) ? String(n).trim() : '';
|
||||
}
|
||||
})
|
||||
}));
|
||||
@ -273,7 +289,7 @@ export default Vue.component(name, Object.assign({}, baseOptions, {
|
||||
#headshot {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background:url('../../resume/id.jpg');
|
||||
background:url('~resume-profile-photo');
|
||||
background-position:center;
|
||||
background-size:cover;
|
||||
}
|
||||
@ -320,7 +336,7 @@ export default Vue.component(name, Object.assign({}, baseOptions, {
|
||||
padding: 6px @pad-x 6px;
|
||||
background: white;
|
||||
|
||||
#experience-title, #education-title, #skills-title, #projects-title {
|
||||
#experience-title, #education-title, #certifications-title, #skills-title, #projects-title {
|
||||
font-size:22px;
|
||||
text-transform:uppercase;
|
||||
}
|
||||
@ -640,6 +656,34 @@ export default Vue.component(name, Object.assign({}, baseOptions, {
|
||||
}
|
||||
}
|
||||
|
||||
#certifications-container {
|
||||
margin-top: 5px;
|
||||
padding-left: 12px;
|
||||
padding-right: 12px;
|
||||
box-sizing: border-box;
|
||||
#certifications-title {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.certifications-list {
|
||||
margin: 0;
|
||||
padding-left: 1.15em;
|
||||
list-style-position: outside;
|
||||
list-style-type: disc;
|
||||
li {
|
||||
font-size: 11px;
|
||||
line-height: 1.3;
|
||||
margin: 0 0 1px 0;
|
||||
padding-left: 2px;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
li::marker {
|
||||
color: @text-green;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -158,7 +158,7 @@ export default Vue.component(name, getVueOptions(name));
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
background-image: url("../../resume/id.jpg");
|
||||
background-image: url("~resume-profile-photo");
|
||||
background-repeat: none;
|
||||
background-position: center;
|
||||
background-size: cover;
|
||||
|
||||
@ -144,7 +144,7 @@ export default Vue.component(name, getVueOptions(name));
|
||||
width:100%;
|
||||
height:100%;
|
||||
border-radius:50%;
|
||||
background-image:url('../../resume/id.jpg');
|
||||
background-image:url('~resume-profile-photo');
|
||||
background-repeat:none;
|
||||
background-position:center;
|
||||
background-size:cover;
|
||||
|
||||
@ -144,7 +144,7 @@ export default Vue.component(name, getVueOptions(name));
|
||||
width:100%;
|
||||
height:100%;
|
||||
border-radius:50%;
|
||||
background-image:url('../../resume/id.jpg');
|
||||
background-image:url('~resume-profile-photo');
|
||||
background-repeat:none;
|
||||
background-position:center;
|
||||
background-size:cover;
|
||||
|
||||
@ -7,13 +7,16 @@
|
||||
<div class="section-headline">
|
||||
{{ lang.contact }}
|
||||
</div>
|
||||
<div class="item">
|
||||
<div v-if="person.birth && person.birth.location" class="item">
|
||||
<div class="icon">
|
||||
<i class="material-icons">account_circle</i>
|
||||
</div>
|
||||
<div class="text">
|
||||
<ul>
|
||||
<li> {{lang.born}} {{person.birth.year}} {{lang.bornIn}} {{person.birth.location}}</li>
|
||||
<li>
|
||||
<template v-if="person.birth.year">{{ lang.born }} {{ person.birth.year }} {{ lang.bornIn }} {{ person.birth.location }}</template>
|
||||
<template v-else>{{ lang.basedIn }} {{ person.birth.location }}</template>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@ -537,7 +540,7 @@ h4 {
|
||||
}
|
||||
}
|
||||
#myselfpic {
|
||||
background-image: url('../../resume/id.jpg');
|
||||
background-image: url('~resume-profile-photo');
|
||||
color: black;
|
||||
}
|
||||
#githubIcon {
|
||||
|
||||
@ -7,13 +7,16 @@
|
||||
<div class="section-headline">
|
||||
{{ lang.contact }}
|
||||
</div>
|
||||
<div v-if="person.birth" class="item">
|
||||
<div v-if="person.birth && person.birth.location" class="item">
|
||||
<div class="icon">
|
||||
<i class="material-icons">account_circle</i>
|
||||
</div>
|
||||
<div class="text">
|
||||
<ul>
|
||||
<li> {{ lang.born }} {{person.birth.year}} {{ lang.bornIn }} {{person.birth.location}}</li>
|
||||
<li>
|
||||
<template v-if="person.birth.year">{{ lang.born }} {{ person.birth.year }} {{ lang.bornIn }} {{ person.birth.location }}</template>
|
||||
<template v-else>{{ lang.basedIn }} {{ person.birth.location }}</template>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@ -488,7 +491,7 @@ h4 {
|
||||
}
|
||||
}
|
||||
#myselfpic {
|
||||
background-image:url('../../resume/id.jpg');
|
||||
background-image:url('~resume-profile-photo');
|
||||
color:black;
|
||||
}
|
||||
#githubIcon {
|
||||
|
||||
@ -137,7 +137,7 @@ export default Vue.component(name, getVueOptions(name));
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
background: url('../../resume/id.jpg');
|
||||
background: url('~resume-profile-photo');
|
||||
background-position: center;
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
@ -121,7 +121,7 @@ export default Vue.component(name, getVueOptions(name));
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
background: url("../../resume/id.jpg");
|
||||
background: url("~resume-profile-photo");
|
||||
background-position: center;
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
@ -122,7 +122,7 @@ export default Vue.component(name, getVueOptions(name));
|
||||
position:absolute;
|
||||
top:0;
|
||||
right:0;
|
||||
background:url('../../resume/id.jpg');
|
||||
background:url('~resume-profile-photo');
|
||||
background-position:center;
|
||||
background-size:cover;
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import yaml from 'js-yaml';
|
||||
import {
|
||||
PERSON
|
||||
} from '../../resume/data.yml';
|
||||
} from 'resume-person-data';
|
||||
import {
|
||||
terms
|
||||
} from '../terms';
|
||||
|
||||
@ -76,6 +76,16 @@
|
||||
<p><span class="degree">{{education.degree}} | </span><span class="education-timeperiod">{{education.timeperiod}}</span></p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="certifications-container" v-if="person.certifications && person.certifications.length">
|
||||
<h2 id="certifications-title">{{ lang.certifications }}</h2>
|
||||
<div class="spacer"></div>
|
||||
<ul class="certifications-list">
|
||||
<li
|
||||
v-for="(c, cidx) in person.certifications"
|
||||
:key="cidx"
|
||||
class="list-item-black">{{ certificationLabel(c) }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div id="skills-container" v-if="person.skills && person.skills.length">
|
||||
<h2 id="skills-title">{{ lang.skills }}</h2>
|
||||
<div class="spacer"></div>
|
||||
@ -105,7 +115,17 @@ import Vue from 'vue';
|
||||
import { getVueOptions } from './options';
|
||||
|
||||
const name = 'purple';
|
||||
export default Vue.component(name, getVueOptions(name));
|
||||
const baseOptions = getVueOptions(name);
|
||||
export default Vue.component(name, Object.assign({}, baseOptions, {
|
||||
methods: Object.assign({}, baseOptions.methods || {}, {
|
||||
certificationLabel (c) {
|
||||
if (c === undefined || c === null) return '';
|
||||
if (typeof c === 'string') return String(c).trim();
|
||||
const n = c.name;
|
||||
return (n !== undefined && n !== null && String(n).trim()) ? String(n).trim() : '';
|
||||
}
|
||||
})
|
||||
}));
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
@ -185,7 +205,7 @@ export default Vue.component(name, getVueOptions(name));
|
||||
#headshot {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background:url('../../resume/id.jpg');
|
||||
background:url('~resume-profile-photo');
|
||||
background-position:center;
|
||||
background-size:cover;
|
||||
}
|
||||
@ -195,7 +215,7 @@ export default Vue.component(name, getVueOptions(name));
|
||||
#resume-body {
|
||||
padding: 40px 100px;
|
||||
|
||||
#experience-title, #education-title, #skills-title {
|
||||
#experience-title, #education-title, #certifications-title, #skills-title {
|
||||
font-size:26px;
|
||||
text-transform:uppercase;
|
||||
}
|
||||
@ -351,9 +371,26 @@ export default Vue.component(name, getVueOptions(name));
|
||||
}
|
||||
}
|
||||
|
||||
#education-container, #skills-container {
|
||||
#education-container, #certifications-container, #skills-container {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.certifications-list {
|
||||
margin: 0 0 0 50px;
|
||||
padding-left: 1.15em;
|
||||
list-style-position: outside;
|
||||
list-style-type: disc;
|
||||
li {
|
||||
font-size: 14px;
|
||||
line-height: 1.35;
|
||||
margin: 0 0 4px 0;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
li::marker {
|
||||
color: @text-purple;
|
||||
}
|
||||
}
|
||||
}
|
||||
#resume-footer {
|
||||
padding: 20px 100px;
|
||||
|
||||
@ -12,6 +12,7 @@ import './cool.vue';
|
||||
import './cool-rtl.vue';
|
||||
import './cool-rtl2.vue';
|
||||
import './green.vue';
|
||||
import './ai-bw.vue';
|
||||
import './left-right-projects.vue';
|
||||
import './material-dark-projects.vue';
|
||||
import './oblique-projects.vue';
|
||||
|
||||
@ -151,7 +151,7 @@ export default Vue.component(name, getVueOptions(name));
|
||||
overflow:hidden;
|
||||
.img {
|
||||
flex:none;
|
||||
background:url('../../resume/id.jpg');
|
||||
background:url('~resume-profile-photo');
|
||||
background-position:center;
|
||||
background-size:cover;
|
||||
height:250px;
|
||||
|
||||
@ -143,7 +143,7 @@ export default Vue.component(name, getVueOptions(name));
|
||||
overflow:hidden;
|
||||
.img {
|
||||
flex:none;
|
||||
background:url('../../resume/id.jpg');
|
||||
background:url('~resume-profile-photo');
|
||||
background-position:center;
|
||||
background-size:cover;
|
||||
height:250px;
|
||||
|
||||
@ -141,7 +141,7 @@ export default Vue.component(name, getVueOptions(name));
|
||||
overflow:hidden;
|
||||
.img {
|
||||
flex:none;
|
||||
background:url('../../resume/id.jpg');
|
||||
background:url('~resume-profile-photo');
|
||||
background-position:center;
|
||||
background-size:cover;
|
||||
height:250px;
|
||||
|
||||
@ -5,12 +5,14 @@ const fs = require('fs');
|
||||
const describe = mocha.describe;
|
||||
const it = mocha.it;
|
||||
const allResumes = require('./allResumes');
|
||||
const { getResumeSlug } = require('../../scripts/resumeSlug');
|
||||
|
||||
describe('npm run export', () => {
|
||||
it('should have generated the pdf files', () => {
|
||||
const slug = getResumeSlug();
|
||||
const resumes = allResumes();
|
||||
resumes.forEach(resume => {
|
||||
const p = path.join(__dirname, '../../pdf/' + resume.path + '.pdf');
|
||||
const p = path.join(__dirname, '../../pdf/' + resume.path + '-' + slug + '.pdf');
|
||||
assert.ok(fs.existsSync(p));
|
||||
});
|
||||
});
|
||||
|
||||
@ -5,12 +5,14 @@ const fs = require('fs');
|
||||
const describe = mocha.describe;
|
||||
const it = mocha.it;
|
||||
const allResumes = require('./allResumes');
|
||||
const { getResumeSlug } = require('../../scripts/resumeSlug');
|
||||
|
||||
describe('npm run preview', () => {
|
||||
it('should have generated the png files', () => {
|
||||
const slug = getResumeSlug();
|
||||
const resumes = allResumes();
|
||||
resumes.forEach(resume => {
|
||||
const p = path.join(__dirname, '../../src/assets/preview/resume-' + resume.path + '.png');
|
||||
const p = path.join(__dirname, '../../src/assets/preview/resume-' + resume.path + '-' + slug + '.png');
|
||||
assert.ok(fs.existsSync(p));
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user