sync-cv-formatter 1.2.0__py3-none-any.whl

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.
@@ -0,0 +1,13 @@
1
+ from sync_cv_formatter.renderers.html_renderer import (
2
+ populate_html_template,
3
+ save_html_file,
4
+ )
5
+ from sync_cv_formatter.renderers.pdf_renderer import render_html_to_pdf
6
+ from sync_cv_formatter.schemas.resume_document import ResumeDocument
7
+
8
+ __all__ = [
9
+ "ResumeDocument",
10
+ "populate_html_template",
11
+ "render_html_to_pdf",
12
+ "save_html_file",
13
+ ]
@@ -0,0 +1,4 @@
1
+ from sync_cv_formatter.renderers.html_renderer import populate_html_template, save_html_file
2
+ from sync_cv_formatter.renderers.pdf_renderer import render_html_to_pdf
3
+
4
+ __all__ = ["populate_html_template", "render_html_to_pdf", "save_html_file"]
@@ -0,0 +1,211 @@
1
+ from datetime import datetime
2
+ from pathlib import Path
3
+ from urllib.parse import urlparse
4
+
5
+ from jinja2 import Environment, FileSystemLoader, select_autoescape
6
+
7
+ from sync_cv_formatter.schemas.resume_document import ResumeDocument, ResumeTemplateId
8
+
9
+ _PKG_DIR = Path(__file__).resolve().parent.parent
10
+ HTML_TEMPLATES_DIR = _PKG_DIR / "templates" / "html"
11
+
12
+ _MONTHS = (
13
+ "Jan",
14
+ "Feb",
15
+ "Mar",
16
+ "Apr",
17
+ "May",
18
+ "Jun",
19
+ "Jul",
20
+ "Aug",
21
+ "Sep",
22
+ "Oct",
23
+ "Nov",
24
+ "Dec",
25
+ )
26
+
27
+
28
+ def _format_resume_date(value: str | None) -> str:
29
+ if not value:
30
+ return "Present"
31
+
32
+ normalized = str(value).strip()
33
+ if normalized.lower() == "present":
34
+ return "Present"
35
+
36
+ try:
37
+ parsed = datetime.fromisoformat(normalized.replace("Z", "+00:00"))
38
+ return f"{_MONTHS[parsed.month - 1]} {parsed.year}"
39
+ except ValueError:
40
+ pass
41
+
42
+ if len(normalized) >= 7 and normalized[4] == "-":
43
+ try:
44
+ year = int(normalized[:4])
45
+ month = int(normalized[5:7])
46
+ if 1 <= month <= 12:
47
+ return f"{_MONTHS[month - 1]} {year}"
48
+ except ValueError:
49
+ pass
50
+
51
+ return normalized
52
+
53
+
54
+ def _format_external_url(url: str | None) -> str:
55
+ if not url:
56
+ return ""
57
+ normalized = str(url).strip()
58
+ return normalized if normalized.startswith("http") else f"https://{normalized}"
59
+
60
+
61
+ def _resume_link_domain(url: str | None) -> str | None:
62
+ href = _format_external_url(url)
63
+ if not href:
64
+ return None
65
+ try:
66
+ hostname = urlparse(href).hostname or ""
67
+ return hostname.removeprefix("www.") or None
68
+ except ValueError:
69
+ return None
70
+
71
+
72
+ def _resume_favicon_url(url: str | None) -> str:
73
+ domain = _resume_link_domain(url)
74
+ if not domain:
75
+ return ""
76
+ return f"https://www.google.com/s2/favicons?domain={domain}&sz=32"
77
+
78
+
79
+ _PLATFORM_LABELS = {
80
+ "linkedin": "LinkedIn",
81
+ "github": "GitHub",
82
+ "gitlab": "GitLab",
83
+ "twitter": "Twitter",
84
+ "x": "X",
85
+ "youtube": "YouTube",
86
+ "instagram": "Instagram",
87
+ "facebook": "Facebook",
88
+ "portfolio": "Portfolio",
89
+ "website": "Website",
90
+ "syncques": "SyncQues",
91
+ "leetcode": "LeetCode",
92
+ "stackoverflow": "Stack Overflow",
93
+ "medium": "Medium",
94
+ "behance": "Behance",
95
+ "dribbble": "Dribbble",
96
+ "credential": "View Credential",
97
+ "live": "Live",
98
+ }
99
+
100
+ _DOMAIN_LABEL_PATTERNS: list[tuple[str, str]] = [
101
+ ("linkedin.com", "LinkedIn"),
102
+ ("github.com", "GitHub"),
103
+ ("gitlab.com", "GitLab"),
104
+ ("twitter.com", "X"),
105
+ ("x.com", "X"),
106
+ ("youtube.com", "YouTube"),
107
+ ("youtu.be", "YouTube"),
108
+ ("instagram.com", "Instagram"),
109
+ ("facebook.com", "Facebook"),
110
+ ("fb.com", "Facebook"),
111
+ ("syncques.com", "SyncQues"),
112
+ ("leetcode.com", "LeetCode"),
113
+ ("stackoverflow.com", "Stack Overflow"),
114
+ ("medium.com", "Medium"),
115
+ ("behance.net", "Behance"),
116
+ ("dribbble.com", "Dribbble"),
117
+ ("credly.com", "Credly"),
118
+ ]
119
+
120
+
121
+ def _resume_link_label(
122
+ platform_or_kind: str, url: str | None = None, fallback: str | None = None
123
+ ) -> str:
124
+ platform_key = platform_or_kind.lower().replace(" ", "_")
125
+ if platform_key in _PLATFORM_LABELS:
126
+ return _PLATFORM_LABELS[platform_key]
127
+
128
+ href = _format_external_url(url)
129
+ if href:
130
+ lower_href = href.lower()
131
+ for domain, label in _DOMAIN_LABEL_PATTERNS:
132
+ if domain in lower_href:
133
+ return label
134
+
135
+ domain = _resume_link_domain(href)
136
+ if domain:
137
+ base = domain.split(".")[0]
138
+ return base[:1].upper() + base[1:]
139
+
140
+ if fallback:
141
+ return fallback
142
+
143
+ return platform_or_kind.replace("_", " ").title()
144
+
145
+
146
+ TEMPLATE_DIRS: dict[ResumeTemplateId, str] = {
147
+ "professional": "professional",
148
+ "executive": "executive",
149
+ "modern": "modern",
150
+ "classic": "classic",
151
+ "compact": "compact",
152
+ }
153
+
154
+ _LEGACY_TEMPLATE_ALIASES: dict[str, ResumeTemplateId] = {
155
+ "creative": "modern",
156
+ }
157
+
158
+
159
+ def _get_template_env(template_id: ResumeTemplateId) -> Environment:
160
+ template_dir = HTML_TEMPLATES_DIR / TEMPLATE_DIRS[template_id]
161
+ env = Environment(
162
+ loader=FileSystemLoader(
163
+ [str(template_dir), str(HTML_TEMPLATES_DIR)],
164
+ ),
165
+ autoescape=select_autoescape(["html", "xml"]),
166
+ )
167
+ env.filters["format_resume_date"] = _format_resume_date
168
+ env.filters["format_external_url"] = _format_external_url
169
+ env.filters["resume_favicon_url"] = _resume_favicon_url
170
+ env.globals["resume_link_label"] = _resume_link_label
171
+ return env
172
+
173
+
174
+ def _load_css(template_id: ResumeTemplateId) -> str:
175
+ css_path = HTML_TEMPLATES_DIR / TEMPLATE_DIRS[template_id] / "styles.css"
176
+ return css_path.read_text(encoding="utf-8")
177
+
178
+
179
+ def _resolve_template_id(
180
+ template_id: str | ResumeTemplateId | None,
181
+ *,
182
+ fallback: ResumeTemplateId = "professional",
183
+ ) -> ResumeTemplateId:
184
+ normalized = str(template_id or fallback).strip().lower()
185
+ if normalized in _LEGACY_TEMPLATE_ALIASES:
186
+ return _LEGACY_TEMPLATE_ALIASES[normalized]
187
+ if normalized in TEMPLATE_DIRS:
188
+ return normalized # type: ignore[return-value]
189
+ return fallback
190
+
191
+
192
+ def populate_html_template(
193
+ document: ResumeDocument,
194
+ template_id: ResumeTemplateId | None = None,
195
+ *,
196
+ interactive: bool = False,
197
+ ) -> str:
198
+ selected_template = _resolve_template_id(template_id or document.template_id)
199
+ env = _get_template_env(selected_template)
200
+ template = env.get_template("template.html.j2")
201
+ return template.render(
202
+ document=document,
203
+ css_content=_load_css(selected_template),
204
+ interactive=interactive,
205
+ )
206
+
207
+
208
+ def save_html_file(content: str, output_path: str) -> str:
209
+ html_path = output_path.replace(".pdf", ".html")
210
+ Path(html_path).write_text(content, encoding="utf-8")
211
+ return html_path
@@ -0,0 +1,42 @@
1
+ from pathlib import Path
2
+
3
+
4
+ def render_html_to_pdf(html_file: str, output_dir: str | None = None) -> tuple[bool, str]:
5
+ html_path = Path(html_file)
6
+
7
+ if not html_path.exists():
8
+ return False, f"HTML file not found: {html_file}"
9
+
10
+ if output_dir is None:
11
+ output_dir = html_path.parent
12
+
13
+ output_dir = Path(output_dir)
14
+ output_dir.mkdir(parents=True, exist_ok=True)
15
+ pdf_path = output_dir / html_path.with_suffix(".pdf").name
16
+
17
+ try:
18
+ from playwright.sync_api import sync_playwright
19
+ except ImportError:
20
+ return False, "Playwright is not installed. Run: playwright install chromium"
21
+
22
+ try:
23
+ file_url = html_path.resolve().as_uri()
24
+ with sync_playwright() as playwright:
25
+ browser = playwright.chromium.launch(headless=True)
26
+ page = browser.new_page()
27
+ page.goto(file_url, wait_until="networkidle")
28
+ page.pdf(
29
+ path=str(pdf_path),
30
+ format="A4",
31
+ print_background=True,
32
+ prefer_css_page_size=True,
33
+ margin={"top": "0", "right": "0", "bottom": "0", "left": "0"},
34
+ )
35
+ browser.close()
36
+
37
+ if pdf_path.exists():
38
+ return True, f"PDF created successfully: {pdf_path}"
39
+
40
+ return False, "PDF file was not created by Playwright"
41
+ except Exception as exc:
42
+ return False, f"HTML to PDF rendering failed: {exc}"
@@ -0,0 +1,27 @@
1
+ from sync_cv_formatter.schemas.resume_document import (
2
+ AchievementItem,
3
+ BasicsSection,
4
+ EducationItem,
5
+ ExperienceItem,
6
+ ProjectItem,
7
+ ResumeDocument,
8
+ ResumeDocumentMetadata,
9
+ ResumeSections,
10
+ ResumeTemplateId,
11
+ ResumeTypeValue,
12
+ SkillsSection,
13
+ )
14
+
15
+ __all__ = [
16
+ "AchievementItem",
17
+ "BasicsSection",
18
+ "EducationItem",
19
+ "ExperienceItem",
20
+ "ProjectItem",
21
+ "ResumeDocument",
22
+ "ResumeDocumentMetadata",
23
+ "ResumeSections",
24
+ "ResumeTemplateId",
25
+ "ResumeTypeValue",
26
+ "SkillsSection",
27
+ ]
@@ -0,0 +1,100 @@
1
+ from datetime import datetime, timezone
2
+ from typing import Literal
3
+
4
+ from pydantic import BaseModel, Field
5
+
6
+ ResumeTemplateId = Literal["professional", "executive", "modern", "classic", "compact"]
7
+ ResumeTypeValue = Literal["standard", "ats_optimized"]
8
+
9
+
10
+ class BasicsSection(BaseModel):
11
+ full_name: str = ""
12
+ email: str = ""
13
+ phone: str | None = None
14
+ location: str | None = None
15
+ summary: str | None = None
16
+ syncques_url: str | None = None
17
+ social_links: dict[str, str] = Field(default_factory=dict)
18
+
19
+
20
+ class SkillsSection(BaseModel):
21
+ flat: list[str] = Field(default_factory=list)
22
+ categorized: dict[str, list[str]] = Field(default_factory=dict)
23
+
24
+
25
+ class ExperienceItem(BaseModel):
26
+ title: str = ""
27
+ company: str = ""
28
+ location: str | None = None
29
+ start_date: str | None = None
30
+ end_date: str | None = "Present"
31
+ description: str = ""
32
+
33
+
34
+ class EducationItem(BaseModel):
35
+ institution: str = ""
36
+ degree: str = ""
37
+ field_of_study: str | None = None
38
+ location: str | None = None
39
+ start_date: str | None = None
40
+ end_date: str | None = "Present"
41
+ gpa: str | None = None
42
+ description: str | None = None
43
+
44
+
45
+ class ProjectItem(BaseModel):
46
+ title: str = ""
47
+ description: str = ""
48
+ technologies: list[str] = Field(default_factory=list)
49
+ project_url: str | None = None
50
+ repository_url: str | None = None
51
+ start_date: str | None = None
52
+ end_date: str | None = None
53
+
54
+
55
+ class AchievementItem(BaseModel):
56
+ title: str = ""
57
+ organization: str | None = None
58
+ description: str | None = None
59
+ date_earned: str | None = None
60
+ credential_url: str | None = None
61
+
62
+
63
+ class ResumeSections(BaseModel):
64
+ skills: SkillsSection = Field(default_factory=SkillsSection)
65
+ experience: list[ExperienceItem] = Field(default_factory=list)
66
+ education: list[EducationItem] = Field(default_factory=list)
67
+ projects: list[ProjectItem] = Field(default_factory=list)
68
+ achievements: list[AchievementItem] = Field(default_factory=list)
69
+
70
+
71
+ class ResumeDocumentMetadata(BaseModel):
72
+ generated_at: str
73
+ resume_type: ResumeTypeValue = "standard"
74
+ job_title: str | None = None
75
+ job_description: str | None = None
76
+ last_edited_at: str | None = None
77
+ content_version: int = 1
78
+
79
+
80
+ class ResumeDocument(BaseModel):
81
+ schema_version: str = "1.0"
82
+ template_id: ResumeTemplateId = "professional"
83
+ basics: BasicsSection = Field(default_factory=BasicsSection)
84
+ sections: ResumeSections = Field(default_factory=ResumeSections)
85
+ metadata: ResumeDocumentMetadata
86
+
87
+ @classmethod
88
+ def default_metadata(
89
+ cls,
90
+ resume_type: ResumeTypeValue = "standard",
91
+ job_title: str | None = None,
92
+ job_description: str | None = None,
93
+ ) -> ResumeDocumentMetadata:
94
+ return ResumeDocumentMetadata(
95
+ generated_at=datetime.now(timezone.utc).isoformat(),
96
+ resume_type=resume_type,
97
+ job_title=job_title,
98
+ job_description=job_description,
99
+ content_version=1,
100
+ )
@@ -0,0 +1,10 @@
1
+ <meta charset="UTF-8" />
2
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
3
+ <title>{{ document.basics.full_name }} — Resume</title>
4
+ <style>{{ css_content }}</style>
5
+ {% if interactive %}
6
+ <style>
7
+ [data-resume-section] { cursor: pointer; border-radius: 2px; }
8
+ [data-resume-section]:hover { background: rgba(0, 51, 153, 0.05); }
9
+ </style>
10
+ {% endif %}
@@ -0,0 +1,43 @@
1
+ {% macro resume_contact(document) %}
2
+ {% set ns = namespace(first=true) %}
3
+ {% if document.basics.email %}
4
+ {% if not ns.first %}<span class="contact-sep">·</span>{% endif %}
5
+ <a href="mailto:{{ document.basics.email }}">{{ document.basics.email }}</a>
6
+ {% set ns.first = false %}
7
+ {% endif %}
8
+ {% if document.basics.phone %}
9
+ {% if not ns.first %}<span class="contact-sep">·</span>{% endif %}
10
+ <a href="tel:{{ document.basics.phone }}">{{ document.basics.phone }}</a>
11
+ {% set ns.first = false %}
12
+ {% endif %}
13
+ {% if document.basics.location %}
14
+ {% if not ns.first %}<span class="contact-sep">·</span>{% endif %}
15
+ <span>{{ document.basics.location }}</span>
16
+ {% set ns.first = false %}
17
+ {% endif %}
18
+ {% if document.basics.syncques_url %}
19
+ {% if not ns.first %}<span class="contact-sep">·</span>{% endif %}
20
+ {{ external_link('syncques', document.basics.syncques_url) }}
21
+ {% set ns.first = false %}
22
+ {% endif %}
23
+ {% for platform, url in document.basics.social_links.items() %}
24
+ {% if url %}
25
+ {% if not ns.first %}<span class="contact-sep">·</span>{% endif %}
26
+ {{ external_link(platform, url) }}
27
+ {% set ns.first = false %}
28
+ {% endif %}
29
+ {% endfor %}
30
+ {% endmacro %}
31
+
32
+ {% macro external_link(platform, url, fallback='') %}
33
+ {% set href = url | format_external_url %}
34
+ {% if href %}
35
+ <a href="{{ href }}" class="resume-external-link">
36
+ {% set favicon = url | resume_favicon_url %}
37
+ {% if favicon %}
38
+ <img src="{{ favicon }}" alt="" class="resume-link-icon" width="12" height="12" />
39
+ {% endif %}
40
+ <span>{{ resume_link_label(platform, url, fallback) }}</span>
41
+ </a>
42
+ {% endif %}
43
+ {% endmacro %}
@@ -0,0 +1,57 @@
1
+ {% if document.basics.summary %}
2
+ <section class="resume-section" data-resume-section="basics">
3
+ <h2 class="resume-section-title">Professional Summary</h2>
4
+ <p class="resume-summary">{{ document.basics.summary }}</p>
5
+ </section>
6
+ {% endif %}
7
+
8
+ {% if document.sections.skills.categorized or document.sections.skills.flat %}
9
+ <section class="resume-section" data-resume-section="skills">
10
+ <h2 class="resume-section-title">Technical Skills</h2>
11
+ {% if document.sections.skills.categorized %}
12
+ <div class="skills-block">
13
+ {% for category, skills in document.sections.skills.categorized.items() %}
14
+ {% if skills %}
15
+ <p class="skill-line"><strong>{{ category }}:</strong> {{ skills | join(', ') }}</p>
16
+ {% endif %}
17
+ {% endfor %}
18
+ </div>
19
+ {% elif document.sections.skills.flat %}
20
+ <p class="skill-inline">{{ document.sections.skills.flat | join(' · ') }}</p>
21
+ {% endif %}
22
+ </section>
23
+ {% endif %}
24
+
25
+ {% if document.sections.experience %}
26
+ <section class="resume-section" data-resume-section="experience">
27
+ <h2 class="resume-section-title">Work Experience</h2>
28
+ {% for exp in document.sections.experience %}
29
+ <article class="entry">
30
+ <div class="entry-header">
31
+ <div class="entry-heading">
32
+ <h3 class="entry-title">
33
+ {{ exp.title }}{% if exp.company %} at {{ exp.company }}{% endif %}{% if exp.location %}, {{ exp.location }}{% endif %}
34
+ </h3>
35
+ </div>
36
+ {% if exp.start_date or exp.end_date %}
37
+ <div class="entry-date">
38
+ {{ exp.start_date | format_resume_date }}{% if exp.start_date or exp.end_date %} – {% endif %}{{ exp.end_date | format_resume_date }}
39
+ </div>
40
+ {% endif %}
41
+ </div>
42
+ {% if exp.description %}
43
+ <ul class="entry-bullets">
44
+ {% for line in exp.description.replace('\\n', '\n').split('\n') %}
45
+ {% set bullet = line.strip().lstrip('•').lstrip('-').lstrip('–').strip() %}
46
+ {% if bullet %}
47
+ <li>{{ bullet }}</li>
48
+ {% endif %}
49
+ {% endfor %}
50
+ </ul>
51
+ {% endif %}
52
+ </article>
53
+ {% endfor %}
54
+ </section>
55
+ {% endif %}
56
+
57
+ {% include '_sections_tail.html.j2' %}
@@ -0,0 +1,57 @@
1
+ {% if document.basics.summary %}
2
+ <section class="resume-section" data-resume-section="basics">
3
+ <h2 class="resume-section-title">Professional Summary</h2>
4
+ <p class="resume-summary">{{ document.basics.summary }}</p>
5
+ </section>
6
+ {% endif %}
7
+
8
+ {% if document.sections.experience %}
9
+ <section class="resume-section" data-resume-section="experience">
10
+ <h2 class="resume-section-title">Work Experience</h2>
11
+ {% for exp in document.sections.experience %}
12
+ <article class="entry">
13
+ <div class="entry-header">
14
+ <div class="entry-heading">
15
+ <h3 class="entry-title">
16
+ {{ exp.title }}{% if exp.company %} at {{ exp.company }}{% endif %}{% if exp.location %}, {{ exp.location }}{% endif %}
17
+ </h3>
18
+ </div>
19
+ {% if exp.start_date or exp.end_date %}
20
+ <div class="entry-date">
21
+ {{ exp.start_date | format_resume_date }}{% if exp.start_date or exp.end_date %} – {% endif %}{{ exp.end_date | format_resume_date }}
22
+ </div>
23
+ {% endif %}
24
+ </div>
25
+ {% if exp.description %}
26
+ <ul class="entry-bullets">
27
+ {% for line in exp.description.replace('\\n', '\n').split('\n') %}
28
+ {% set bullet = line.strip().lstrip('•').lstrip('-').lstrip('–').strip() %}
29
+ {% if bullet %}
30
+ <li>{{ bullet }}</li>
31
+ {% endif %}
32
+ {% endfor %}
33
+ </ul>
34
+ {% endif %}
35
+ </article>
36
+ {% endfor %}
37
+ </section>
38
+ {% endif %}
39
+
40
+ {% if document.sections.skills.categorized or document.sections.skills.flat %}
41
+ <section class="resume-section" data-resume-section="skills">
42
+ <h2 class="resume-section-title">Technical Skills</h2>
43
+ {% if document.sections.skills.categorized %}
44
+ <div class="skills-block">
45
+ {% for category, skills in document.sections.skills.categorized.items() %}
46
+ {% if skills %}
47
+ <p class="skill-line"><strong>{{ category }}:</strong> {{ skills | join(', ') }}</p>
48
+ {% endif %}
49
+ {% endfor %}
50
+ </div>
51
+ {% elif document.sections.skills.flat %}
52
+ <p class="skill-inline">{{ document.sections.skills.flat | join(' · ') }}</p>
53
+ {% endif %}
54
+ </section>
55
+ {% endif %}
56
+
57
+ {% include '_sections_tail.html.j2' %}
@@ -0,0 +1,95 @@
1
+ {% if document.sections.education %}
2
+ <section class="resume-section" data-resume-section="education">
3
+ <h2 class="resume-section-title">Education</h2>
4
+ {% for edu in document.sections.education %}
5
+ <article class="entry">
6
+ <div class="entry-header">
7
+ <div class="entry-heading">
8
+ <h3 class="entry-title">{{ edu.institution }}</h3>
9
+ <p class="entry-subtitle">
10
+ {{ edu.degree }}{% if edu.field_of_study %} in {{ edu.field_of_study }}{% endif %}
11
+ {%- if edu.location %} · {{ edu.location }}{% endif -%}
12
+ </p>
13
+ </div>
14
+ {% if edu.start_date or edu.end_date %}
15
+ <div class="entry-date">
16
+ {{ edu.start_date | format_resume_date }}{% if edu.start_date or edu.end_date %} – {% endif %}{{ edu.end_date | format_resume_date }}
17
+ </div>
18
+ {% endif %}
19
+ </div>
20
+ {% if edu.gpa or edu.description %}
21
+ <p class="entry-detail">
22
+ {% if edu.gpa %}<em>GPA:</em> {{ edu.gpa }}{% endif %}
23
+ {% if edu.gpa and edu.description %} · {% endif %}
24
+ {% if edu.description %}{{ edu.description }}{% endif %}
25
+ </p>
26
+ {% endif %}
27
+ </article>
28
+ {% endfor %}
29
+ </section>
30
+ {% endif %}
31
+
32
+ {% if document.sections.projects %}
33
+ <section class="resume-section" data-resume-section="projects">
34
+ <h2 class="resume-section-title">Projects</h2>
35
+ {% for proj in document.sections.projects %}
36
+ <article class="entry">
37
+ <div class="entry-header">
38
+ <h3 class="entry-title">{{ proj.title }}</h3>
39
+ {% if proj.repository_url or proj.project_url %}
40
+ <div class="entry-links">
41
+ {% if proj.repository_url %}
42
+ {{ external_link('github', proj.repository_url, 'Repository') }}
43
+ {% endif %}
44
+ {% if proj.project_url %}
45
+ {{ external_link('live', proj.project_url, 'Live') }}
46
+ {% endif %}
47
+ </div>
48
+ {% endif %}
49
+ </div>
50
+ {% if proj.description %}
51
+ <ul class="entry-bullets">
52
+ {% for line in proj.description.replace('\\n', '\n').split('\n') %}
53
+ {% set bullet = line.strip().lstrip('•').lstrip('-').lstrip('–').strip() %}
54
+ {% if bullet %}
55
+ <li>{{ bullet }}</li>
56
+ {% endif %}
57
+ {% endfor %}
58
+ </ul>
59
+ {% endif %}
60
+ {% if proj.technologies %}
61
+ <p class="entry-detail"><em>Technologies:</em> {{ proj.technologies | join(', ') }}</p>
62
+ {% endif %}
63
+ </article>
64
+ {% endfor %}
65
+ </section>
66
+ {% endif %}
67
+
68
+ {% if document.sections.achievements %}
69
+ <section class="resume-section" data-resume-section="achievements">
70
+ <h2 class="resume-section-title">Achievements &amp; Certifications</h2>
71
+ {% for ach in document.sections.achievements %}
72
+ <article class="entry">
73
+ <div class="entry-header">
74
+ <div class="entry-heading">
75
+ <h3 class="entry-title">{{ ach.title }}</h3>
76
+ {% if ach.organization %}
77
+ <p class="entry-meta">{{ ach.organization }}</p>
78
+ {% endif %}
79
+ </div>
80
+ {% if ach.date_earned %}
81
+ <div class="entry-date">{{ ach.date_earned | format_resume_date }}</div>
82
+ {% endif %}
83
+ </div>
84
+ {% if ach.description %}
85
+ <p class="entry-detail">{{ ach.description }}</p>
86
+ {% endif %}
87
+ {% if ach.credential_url %}
88
+ <div class="entry-links">
89
+ {{ external_link('credential', ach.credential_url, 'View Credential') }}
90
+ </div>
91
+ {% endif %}
92
+ </article>
93
+ {% endfor %}
94
+ </section>
95
+ {% endif %}