Theme Unification,
Dark Mode & i18n
A complete guide to merging your two visual worlds, adding dark mode, multilingual support, and understanding every knob in _config.yml.
The Problem: Two Visual Worlds
Your site currently has a split personality. The blog/tutorial side uses the Mundana theme (Bootstrap 4, Lora serif font, white backgrounds, teal accent #03a87c). The resume page uses the Jekyll Professional Resume theme (completely custom CSS in style.scss, warm earth tones like #7FA093, a separate sidebar, its own font stack).
The clash is deep — they share almost no CSS variables, use different color philosophies, and the resume layout even has its own duplicate <html>, navbar, and MathJax block. Here's what's visually inconsistent:
| Property | Blog/Tutorial (Mundana) | Resume (jekyll-resume) |
|---|---|---|
| Background | #fff pure white | #fffdfb warm ivory |
| Primary accent | #03a87c teal-green | #7FA093 muted sage |
| Font family | Lora + Poppins | Poppins only |
| Headings | Georgia serif | Poppins sans-serif |
| Link color | teal #03a87c | dark sage #3D725D |
| Layout system | Bootstrap 4 grid | Custom CSS grid + fixed sidebar |
| Dark mode | None | None |
| Multilingual | None | None |
Unification Strategy
The approach: keep Bootstrap 4 as the layout backbone (the blog side wins because it's the majority of the site), and fold the resume's visual personality into a shared CSS variable system. We don't rewrite the resume layout from scratch — we replace its hardcoded colors with the same variables the blog uses, fix the duplicate HTML problem, and route it through default.html.
What we keep
- ✓ Bootstrap 4 grid (main.css)
- ✓ Lunr search
- ✓ Resume data structure (_data/*.yml)
- ✓ Resume sidebar animation (main.js)
- ✓ MathJax for posts
- ✓ jekyll-scholar BibTeX
What we change
- ✗ Duplicate <html> in resume layout
- ✗ Hardcoded hex colors everywhere
- ✗ Two separate font stacks
- ✗ style.scss warm palette (absorbed into variables)
- ✗ http:// MathJax in resume-layout.html
- ✗ Dead rawgit CDN (already covered)
The result will be: one _config.yml master list of CSS variables, one default.html that both blog and resume route through, one dark mode toggle that works everywhere, and a language switcher that reads from _data/i18n/.
Part 1 — Design Tokens
Create the CSS Variable System
Replace the hardcoded colors in both themes with a single source of truth. Create a new file assets/css/tokens.css. This will be loaded in default.html before everything else.
/* assets/css/tokens.css
Single source of truth for all design decisions.
Edit ONLY this file to retheme the whole site. */
:root {
/* ── Brand ── */
--accent: #03a87c; /* your teal — kept from Mundana */
--accent-hover: #028f6a;
--accent-light: #e8f3ec;
--accent-dark: #025c44;
/* ── Surfaces ── */
--bg: #ffffff;
--bg-subtle: #f8f9fa;
--surface: #ffffff;
--surface-raised:#f4f6f8;
--border: #e9ecef;
--border-strong: #ced4da;
/* ── Text ── */
--text-primary: #212529;
--text-secondary:#6c757d;
--text-muted: #adb5bd;
--text-inverse: #ffffff;
/* ── Typography ── */
--font-body: "Poppins", "Segoe UI", sans-serif;
--font-serif: "Lora", Georgia, serif;
--font-mono: "Fira Code", "Courier New", monospace;
--font-size-base:1rem;
--line-height: 1.65;
/* ── Resume-specific (absorbed from style.scss) ── */
--resume-profile-bg: #7FA093; /* sage green band */
--resume-accent: #03a87c; /* now same as main accent */
--resume-timeline: #c8d8d2;
--resume-sidebar-bg: #f0f5f3;
--resume-icon-filter: invert(40%) sepia(30%) saturate(400%) hue-rotate(120deg);
/* ── Navbar ── */
--navbar-bg: var(--surface);
--navbar-border: var(--border);
--navbar-text: var(--text-secondary);
/* ── Shadows ── */
--shadow-sm: 0 1px 3px rgba(0,0,0,0.08);
--shadow-md: 0 4px 12px rgba(0,0,0,0.10);
/* ── Transitions ── */
--transition: 0.2s ease;
}
/* ════════════════════════════════════════
DARK MODE — toggled by data-theme="dark"
on <html>. All other CSS is unchanged.
════════════════════════════════════════ */
[data-theme="dark"] {
--bg: #0f1117;
--bg-subtle: #161b26;
--surface: #1a1f2e;
--surface-raised:#222840;
--border: #2a3045;
--border-strong: #3a4060;
--text-primary: #e8eaed;
--text-secondary:#9aa3b2;
--text-muted: #5c6478;
--text-inverse: #0f1117;
--accent: #05d4a0; /* slightly brighter on dark bg */
--accent-hover: #04b88c;
--accent-light: #0a2e24;
--navbar-bg: #12161f;
--navbar-border: #1e2436;
--navbar-text: #9aa3b2;
--resume-profile-bg: #1e3a30;
--resume-sidebar-bg: #161b26;
--resume-timeline: #2a3a35;
--resume-icon-filter: invert(70%) sepia(20%) saturate(300%) hue-rotate(100deg);
}
/* ── Apply tokens to Bootstrap overrides ── */
body {
background-color: var(--bg);
color: var(--text-primary);
transition: background-color var(--transition), color var(--transition);
}
a { color: var(--accent); }
a:hover { color: var(--accent-hover); }
.bg-white, .card {
background-color: var(--surface) !important;
color: var(--text-primary);
}
.text-muted { color: var(--text-secondary) !important; }
.border, .border-top, .border-bottom {
border-color: var(--border) !important;
}
.bg-lightblue { background-color: var(--accent-light) !important; }
.spanborder { border-bottom-color: var(--accent-light) !important; }
data-theme="dark" on <html>, the dark-mode block overrides all those variables at once. Bootstrap's hardcoded bg-white, .text-muted etc. are patched at the bottom.Dark Mode Toggle
Add this to assets/js/theme.js (your existing theme file — add at the bottom, don't replace):
// ── Dark Mode (append to existing assets/js/theme.js) ──
(function() {
const html = document.documentElement;
const KEY = 'expred-theme';
// 1. Restore saved preference (instant, before paint)
const saved = localStorage.getItem(KEY);
if (saved) {
html.setAttribute('data-theme', saved);
} else if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
html.setAttribute('data-theme', 'dark');
}
// 2. Toggle function called by the button in the navbar
window.toggleTheme = function() {
const current = html.getAttribute('data-theme');
const next = current === 'dark' ? 'light' : 'dark';
html.setAttribute('data-theme', next);
localStorage.setItem(KEY, next);
// Update button icon
const btn = document.getElementById('theme-toggle');
if (btn) btn.textContent = next === 'dark' ? '☀' : '☽';
};
// 3. Sync OS-level changes
window.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', function(e) {
if (!localStorage.getItem(KEY)) { // only if user hasn't set manual pref
html.setAttribute('data-theme', e.matches ? 'dark' : 'light');
}
});
})();
<script src=".../theme.js"></script> in the <head> of default.html — before the CSS link tags. This is the only JavaScript that should be in the head.The toggle button to add to your navbar (see Step 2.3):
<button id="theme-toggle"
onclick="toggleTheme()"
class="btn btn-sm btn-outline-secondary ml-2"
aria-label="Toggle dark mode"
title="Toggle dark mode"
style="border-radius:50%;width:34px;height:34px;padding:0;font-size:1rem">
☽
</button>
The icon switches between ☽ (moon = click for dark) and ☀ (sun = click for light). Since the script runs in <head> before the button exists in the DOM, we sync the icon on DOMContentLoaded:
// Also in theme.js, at the end:
document.addEventListener('DOMContentLoaded', function() {
const btn = document.getElementById('theme-toggle');
if (btn) {
btn.textContent = document.documentElement.getAttribute('data-theme') === 'dark' ? '☀' : '☽';
}
});
Unified Typography
The blog uses Lora (serif) for post headlines and Poppins for body. The resume uses only Poppins. Unify this in tokens.css by standardizing the font-family tokens, then update the key places that hardcode font families.
In assets/css/theme.css, find and replace the hardcoded font references:
- .navbar-brand { font-family: "Poppins", Georgia, "Times New Roman", serif; }
+ .navbar-brand { font-family: var(--font-body); }
- article { font-family: "Poppins", sans-serif; }
+ article { font-family: var(--font-body); }
- .article-headline { font-family: "Poppins", Georgia, Times, "Times New Roman", serif; }
+ .article-headline { font-family: var(--font-serif); }
- .jumbotron-home h1 { font-family: Georgia, Times, "Times New Roman", serif; }
+ .jumbotron-home h1 { font-family: var(--font-serif); }
In assets/css/style.scss, find the body font declaration:
- font-family: "Poppins", "Segoe UI", "Helvetica Neue", "Arial";
+ font-family: var(--font-body);
Also update the Google Fonts link in default.html to load both fonts together efficiently:
- <link href="https://fonts.googleapis.com/css?family=Lora:400,400i,700" rel="stylesheet">
- <link href="https://fonts.googleapis.com/css?family=Poppins" rel="stylesheet">
+ <link rel="preconnect" href="https://fonts.googleapis.com">
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+ <link href="https://fonts.googleapis.com/css2?family=Lora:ital,wght@0,400;0,700;1,400&family=Poppins:wght@400;500;600&display=swap" rel="stylesheet">
display=swap prevents invisible text while fonts load — critical for your math-heavy posts which take a moment to render MathJax anyway.Part 2 — Layout Unification
default.html — Add tokens, remove duplicates
Your default.html needs four changes: load tokens.css first, fix the MathJax, fix the rawgit CDN, and add the theme toggle script to <head>.
The key structure of the updated <head> block (full relevant section):
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<!-- DARK MODE: must run BEFORE CSS to prevent flash -->
<script>
(function(){
var t = localStorage.getItem('expred-theme')
|| (window.matchMedia('(prefers-color-scheme:dark)').matches ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', t);
})();
</script>
<!-- SEO (jekyll-seo-tag) -->
{% seo %}
<link rel="canonical" href="{{ page.url | absolute_url }}">
<link rel="shortcut icon" href="{{site.baseurl}}/{{site.favicon}}">
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Lora:ital,wght@0,400;0,700;1,400&family=Poppins:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.3.1/css/all.css" crossorigin="anonymous">
<!-- CSS: tokens FIRST, then Bootstrap, then theme overrides -->
<link rel="stylesheet" href="{{site.baseurl}}/assets/css/tokens.css">
<link rel="stylesheet" href="{{site.baseurl}}/assets/css/main.css">
<link rel="stylesheet" href="{{site.baseurl}}/assets/css/theme.css">
<link rel="stylesheet" href="{{site.baseurl}}/assets/css/monokai.css">
<!-- style.css (resume) loaded only on resume page -->
{% if page.layout == "resume-layout" %}
<link rel="stylesheet" href="{{site.baseurl}}/assets/css/style.css">
{% endif %}
<!-- Bootstrap TOC (fixed CDN) -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/dist/bootstrap-toc.min.css"/>
<!-- MathJax v3 (replaces broken v2) -->
<script>
window.MathJax = {
tex: { inlineMath: [['$','$'],['\\(','\\)']], tags: 'ams' },
svg: { fontCache: 'global' }
};
</script>
<script id="MathJax-script" async
src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-svg.js"></script>
<!-- jQuery (keep in head for Bootstrap) -->
<script src="https://code.jquery.com/jquery-3.3.1.min.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bootstrap-toc.min.js"></script>
{% include tracking-header.html %}
</head>
Fix resume-layout.html — remove the duplicate HTML
The resume layout currently declares its own <!DOCTYPE html>, <head>, <body>, and has its own copy of MathJax (using http:// — not even https). This means the resume page is entirely cut off from default.html — dark mode, the shared navbar fixes, and tokens.css don't apply to it.
The fix: convert resume-layout.html to a layout that inherits from default.html, just like post.html does. Replace the entire file with:
---
layout: default
---
{%- assign meta = "" | split: "" -%}
{%- for yml_file in site.data -%}
{%- unless yml_file[0] == "images" or yml_file[0] == "courses" -%}
{%- assign meta = meta | push: yml_file[1] -%}
{%- endunless -%}
{%- endfor -%}
{%- assign meta = meta | sort: 'listing-order' -%}
<div id="sidebar">
<ul class="toc-box"></ul>
</div>
<div id="about">
<div class="profile-zone">
<img class="profile-img"
src="{{ site.profile_img | prepend: site.baseurl }}"
alt="{{ site.name }}">
<ul class="personal-info">
{%- if site.phone_number -%}
<li><a aria-label="Phone">
<img src="{{site.baseurl}}/assets/images/resume/phone.svg" alt="Phone">
<div><span>{{ site.phone_number }}</span></div>
</a></li>
{%- endif -%}
{%- if site.address -%}
<li><a href="https://www.google.com/maps/search/{{ site.address }}">
<img src="{{site.baseurl}}/assets/images/resume/location-dot.svg" alt="Address">
<div><span>{{ site.address }}</span></div>
</a></li>
{%- endif -%}
{%- if site.email -%}
<li><a href="mailto:{{ site.email }}">
<img src="{{site.baseurl}}/assets/images/resume/envelope.svg" alt="Email">
<div><span>{{ site.email }}</span></div>
</a></li>
{%- endif -%}
{%- if site.linkedin_username -%}
<li><a href="https://www.linkedin.com/in/{{ site.linkedin_username }}">
<img src="{{site.baseurl}}/assets/images/resume/linkedin.svg" alt="LinkedIn">
<div><span>@{{ site.linkedin_username }}</span></div>
</a></li>
{%- endif -%}
{%- if site.github_username -%}
<li><a href="https://github.com/{{ site.github_username }}">
<img src="{{site.baseurl}}/assets/images/resume/github.svg" alt="GitHub">
<div><span>@{{ site.github_username }}</span></div>
</a></li>
{%- endif -%}
{%- if site.youtube_username -%}
<li><a href="https://www.youtube.com/@{{ site.youtube_username }}">
<img src="{{site.baseurl}}/assets/images/resume/youtube.svg" alt="YouTube">
<div><span>@{{ site.youtube_username }}</span></div>
</a></li>
{%- endif -%}
</ul>
</div>
<div class="name-zone">
<h1><strong>{{ site.name }}</strong></h1>
<h3>{{ site.job }}</h3>
<!-- Language-aware CV download (see Part 3) -->
<h3>
{%- assign t = site.i18n[page.lang | default: site.default_lang] -%}
{{ t.resume_download }}: <a href="{{site.baseurl}}/assets/pdf/{{ t.cv_filename }}">
Curriculum Vitae
</a>
</h3>
</div>
</div>
<div id="contents">
<ul>
{%- for subject in meta -%}
<li class="subject" id="{{ subject.subject }}">
<h2 class="subject-name">
<div><img class="subject-icon"
src="{{ subject.icon | prepend: site.baseurl }}"
alt="{{ subject.subject }}"></div>
<b style="text-transform:uppercase;font-size:1.5rem">{{ subject.subject }}</b>
</h2>
<ul>
{%- for member in subject.contents -%}
<li class="item">
<div class="content-header">
{{ member.title | markdownify }}
{%- if member.date -%}
<span class="content-date">
<img src="{{site.baseurl}}/assets/images/resume/calendar.webp" alt="date">
{{ member.date }}
</span>
{%- endif -%}
</div>
{%- for item in member -%}
{%- unless item[0] == "title" or item[0] == "date" -%}
{%- if item[1].first -%}
<ul>
{%- for subitem in item[1] -%}
<li class="subitem">{{ subitem | markdownify }}</li>
{%- endfor -%}
</ul>
{%- else -%}
{{ item[1] | markdownify }}
{%- endif -%}
{%- endunless -%}
{%- endfor -%}
</li>
{%- endfor -%}
</ul>
</li>
{%- endfor -%}
</ul>
</div>
<div id="footer">
<span>{{ site.name }} · {{ site.url }}</span>
</div>
<script src="{{ site.baseurl }}/assets/js/main.js"></script>
layout: default at the top replaces the entire duplicate HTML shell; (2) the data-theme attribute set in default.html now applies to the resume too; (3) i18n is used for the CV download text.Now update assets/css/style.scss to use CSS variables instead of hardcoded colors. The key replacements:
/* profile-zone background */
- background-color: var(--theme4-medium); /* old: #7FA093 */
+ background-color: var(--resume-profile-bg);
/* profile image border */
- border: 4px solid var(--theme3-dark); /* old: #A8955A */
+ border: 4px solid var(--accent);
/* timeline line */
- background: var(--theme3-medium); /* old: #EBDFBB */
+ background: var(--resume-timeline);
/* sidebar background */
- background: var(--theme3-medium);
+ background: var(--resume-sidebar-bg);
/* sidebar active/hover */
- background: var(--theme1-medium); /* old: #D0A694 */
+ background: var(--accent-light);
/* content links */
- color: var(--theme4-dark); /* old: #3D725D */
+ color: var(--accent);
/* footer background */
- background: var(--theme3-medium);
+ background: var(--surface-raised);
+ border-top: 1px solid var(--border);
/* icon filter */
- filter: invert(27%) sepia(2%) saturate(3297%) hue-rotate(8deg);
+ filter: var(--resume-icon-filter);
/* dark mode: body background override */
- background: var(--color-background); /* #fffdfb — breaks dark mode */
+ background: var(--bg);
/* content text */
- color: var(--font-dark); /* #202424 */
+ color: var(--text-primary);
Part 3 — Multilingual (i18n)
Strategy — no plugins, pure Liquid
The cleanest approach for a static site with 2–3 languages is data-file translation: all UI strings live in _data/i18n/, each layout reads from the correct language file based on a lang: front matter variable. Posts and lessons keep their own language files in separate subdirectories.
Supported languages
- 🇫🇷 fr — French (your default)
- 🇬🇧 en — English
- 🇻🇳 vi — Vietnamese
What gets translated
- UI strings (navbar, buttons, labels)
- Resume section labels
- Course/lesson UI text
- Post dates, meta text
Create _data/i18n/ files
i18n/
fr.yml
en.yml
vi.yml
# _data/i18n/fr.yml (French — your default)
lang_name: "Français"
lang_flag: "🇫🇷"
# Navigation
nav_home: "Accueil"
nav_resume: "Résumé"
nav_courses: "Cours"
nav_tutorials: "Tutoriels"
nav_categories: "Catégories"
nav_contact: "Contact"
# Common
read_more: "Lire la suite"
published_on: "Publié le"
min_read: "min de lecture"
share_this: "Partager"
all_posts: "Tous les articles"
featured: "À la une"
search_placeholder: "Rechercher..."
# Resume
resume_download: "Télécharger"
cv_filename: "VO_Duc_Tri_CV_FR.pdf"
# Courses
course_start: "Commencer"
lesson_complete: "Marquer comme terminé ✓"
lesson_completed:"✓ Terminé"
quiz_title: "📝 Quiz de compréhension"
quiz_correct: "✓ Correct !"
quiz_wrong: "✗ Pas tout à fait."
quiz_retry: "Réessayer"
# 404
not_found_title: "Page introuvable"
not_found_text: "La page que vous cherchez n'existe pas."
go_home: "Retour à l'accueil"
# _data/i18n/en.yml (English)
lang_name: "English"
lang_flag: "🇬🇧"
nav_home: "Home"
nav_resume: "Résumé"
nav_courses: "Courses"
nav_tutorials: "Tutorials"
nav_categories: "Categories"
nav_contact: "Contact"
read_more: "Read more"
published_on: "Published on"
min_read: "min read"
share_this: "Share this"
all_posts: "All Posts"
featured: "Featured"
search_placeholder: "Search..."
resume_download: "Download"
cv_filename: "VO_Duc_Tri_CV_EN.pdf"
course_start: "Start Course"
lesson_complete: "Mark as Complete ✓"
lesson_completed:"✓ Completed"
quiz_title: "📝 Knowledge Check"
quiz_correct: "✓ Correct!"
quiz_wrong: "✗ Not quite."
quiz_retry: "Try Again"
not_found_title: "Page not found"
not_found_text: "The page you're looking for doesn't exist."
go_home: "Go Home"
# _data/i18n/vi.yml (Vietnamese)
lang_name: "Tiếng Việt"
lang_flag: "🇻🇳"
nav_home: "Trang chủ"
nav_resume: "Sơ yếu lý lịch"
nav_courses: "Khoá học"
nav_tutorials: "Hướng dẫn"
nav_categories: "Danh mục"
nav_contact: "Liên hệ"
read_more: "Đọc thêm"
published_on: "Đăng ngày"
min_read: "phút đọc"
share_this: "Chia sẻ"
all_posts: "Tất cả bài viết"
featured: "Nổi bật"
search_placeholder: "Tìm kiếm..."
resume_download: "Tải xuống"
cv_filename: "VO_Duc_Tri_CV_VI.pdf"
course_start: "Bắt đầu khoá học"
lesson_complete: "Đánh dấu hoàn thành ✓"
lesson_completed:"✓ Đã hoàn thành"
quiz_title: "📝 Kiểm tra kiến thức"
quiz_correct: "✓ Chính xác!"
quiz_wrong: "✗ Chưa đúng."
quiz_retry: "Thử lại"
not_found_title: "Không tìm thấy trang"
not_found_text: "Trang bạn tìm kiếm không tồn tại."
go_home: "Về trang chủ"
Add language config to _config.yml
# Add to _config.yml
# Default language for the site
default_lang: fr
# Supported languages (used by the language switcher)
languages:
- fr
- en
- vi
# Inline i18n shortcut (so layouts can do site.i18n.fr.nav_home)
# This works because _data/i18n/fr.yml etc are loaded as site.data.i18n.fr
# In Liquid: assign t = site.data.i18n[page.lang | default: site.default_lang]
_data/i18n/fr.yml as site.data.i18n.fr. So to get a translated string in any layout, you write: {%- assign t = site.data.i18n[page.lang | default: site.default_lang] -%} and then use {{ t.nav_home }} anywhere in the template.Use translations in templates
Add the translation lookup to the top of _layouts/default.html (just after the <body> tag, before the navbar):
{%- assign t = site.data.i18n[page.lang | default: site.default_lang] -%}
Then in the navbar <ul> (replacing the hardcoded English text):
<a class="nav-link" href="{{site.baseurl}}/index.html">{{ t.nav_home }}</a>
<a class="nav-link" href="{{site.baseurl}}/resume.html">{{ t.nav_resume }}</a>
<a class="nav-link" href="{{site.baseurl}}/courses/">{{ t.nav_courses }}</a>
In your post layouts, use translation strings for meta text:
{%- assign t = site.data.i18n[page.lang | default: site.default_lang] -%}
<!-- Replace hardcoded "Share this" -->
<div class="text-muted">{{ t.share_this }}</div>
<!-- Replace hardcoded "All Posts" -->
<h3 class="spanborder"><span>{{ t.all_posts }}</span></h3>
<!-- Replace hardcoded "Featured" -->
<h3 class="spanborder"><span>{{ t.featured }}</span></h3>
<!-- Date line in post header -->
<span>{{ t.published_on }} {{ page.date | date: '%d %b %Y' }}</span>
For posts themselves, add lang: fr (or en / vi) to their front matter. If omitted, site.default_lang (fr) applies:
---
title: "Kinematics of Planar Robots"
lang: en # ← add this
categories: [Robotics]
---
Language Switcher include
Create _includes/lang-switcher.html. This renders a small dropdown showing the current language and links to switch. The switcher stores the preference in localStorage and redirects to the same-path URL with ?lang= prefix — or simply reloads the page and lets the lang front matter variable control the display.
<!-- _includes/lang-switcher.html -->
{%- assign t = site.data.i18n[page.lang | default: site.default_lang] -%}
{%- assign current_lang = page.lang | default: site.default_lang -%}
<div class="nav-item dropdown ml-2">
<button class="btn btn-sm btn-outline-secondary dropdown-toggle"
type="button" id="langDropdown"
data-toggle="dropdown"
aria-expanded="false"
style="border-color:var(--border);color:var(--text-primary);font-size:0.78rem">
{{ t.lang_flag }} {{ t.lang_name }}
</button>
<div class="dropdown-menu dropdown-menu-right"
aria-labelledby="langDropdown"
style="min-width:120px;background:var(--surface)">
{%- for lang in site.languages -%}
{%- assign lt = site.data.i18n[lang] -%}
<a class="dropdown-item{% if lang == current_lang %} active{% endif %}"
href="javascript:void(0)"
onclick="switchLang('{{ lang }}')"
style="font-size:0.85rem">
{{ lt.lang_flag }} {{ lt.lang_name }}
</a>
{%- endfor -%}
</div>
</div>
<script>
function switchLang(lang) {
// Save preference
localStorage.setItem('expred-lang', lang);
// Try to find equivalent page in new language.
// Convention: /en/posts/..., /fr/posts/..., or ?lang= query param.
// Simplest approach for a mostly French site: redirect to homepage
// of the target language, letting readers navigate from there.
const langPaths = {
'fr': '{{ site.baseurl }}/',
'en': '{{ site.baseurl }}/en/',
'vi': '{{ site.baseurl }}/vi/'
};
// If same language, do nothing
if (lang === '{{ current_lang }}') return;
window.location.href = langPaths[lang] || '{{ site.baseurl }}/';
}
</script>
/ (root, your current setup unchanged), English content lives at /en/, Vietnamese at /vi/. Create _pages/en/index.html and _pages/vi/index.html with lang: en front matter. Posts in English go in _posts/en/ folder and set lang: en. The language switcher redirects to those roots. This way you can add multilingual content gradually without touching existing French URLs.Part 4 — Mastering _config.yml
The complete annotated _config.yml
Here is your full _config.yml rewritten with every option explained. Edit this file directly to configure the site — most day-to-day customization happens here.
# ════════════════════════════════════════════════════
# _config.yml — expred.co / ductrivo.github.io
# WHAT IS THIS FILE?
# Jekyll reads this once at build time. Anything you
# set here becomes available in templates as {{ site.X }}
# ════════════════════════════════════════════════════
# ──────────────────────────────────────────────────
# 1. SITE IDENTITY
# These appear in the navbar, SEO tags, footer, and
# the resume page. Change these first when personalizing.
# ──────────────────────────────────────────────────
name: 'Duc-Tri VO' # displayed in navbar brand
description: "Your site tagline here" # used by jekyll-seo-tag
url: "https://expred.co" # full URL, no trailing slash
baseurl: '' # '' for root, '/subdir' if served under a subpath
logo: 'assets/images/avatar1.jpg'
favicon: 'assets/images/favicon_VDT.png'
email: '[email protected]'
# ──────────────────────────────────────────────────
# 2. RESUME PAGE SETTINGS
# Used by _layouts/resume-layout.html
# ──────────────────────────────────────────────────
title_resume: 'Résumé — Duc-Tri VO'
profile_img: '/assets/images/resume/photo.jpg'
icon_img: '/assets/images/resume/icon.webp'
job: "Ingénieur en Informatique Industrielle"
phone_number: '+33 07 65 80 35 49' # remove line to hide phone
address: 'Valence, France'
# ──────────────────────────────────────────────────
# 3. SOCIAL LINKS
# Each appears in the resume sidebar. Comment out
# any you don't want to show.
# ──────────────────────────────────────────────────
linkedin_username: voductri97
github_username: ductrivo
youtube_username: ductrivo.official
blog_url: https://expred.co
# twitter_username: twitter # commented = not shown
# instagram_username: instagram
# facebook_username: facebook
# ──────────────────────────────────────────────────
# 4. MULTILINGUAL
# ──────────────────────────────────────────────────
default_lang: fr
languages: [fr, en, vi]
# ──────────────────────────────────────────────────
# 5. JEKYLL CORE
# ──────────────────────────────────────────────────
include: ["_pages"]
permalink: /:title/ # URL style for posts (/my-post-title/)
excerpt_separator: "<!-- excerpt -->" # put this in posts to control excerpt
markdown: kramdown
highlighter: rouge
kramdown:
syntax_highlighter_opts:
block:
line_numbers: true
# ──────────────────────────────────────────────────
# 6. PAGINATION
# Controls how many posts appear per page on index.html
# ──────────────────────────────────────────────────
paginate: 10 # change to 6 or 8 for a tighter layout
# ──────────────────────────────────────────────────
# 7. TABLE OF CONTENTS (jekyll-toc)
# Controls which headings appear in the post TOC
# ──────────────────────────────────────────────────
toc:
min_level: 2 # h2 is the first TOC level
max_level: 4 # h4 is the deepest. Set to 3 to keep it simpler
no_toc_section_class: no_toc
list_class: toc-list
sublist_class: toc-sublist
item_class: toc-item
# ──────────────────────────────────────────────────
# 8. PLUGINS
# ──────────────────────────────────────────────────
plugins:
- jekyll-feed # generates /feed.xml for RSS readers
- jekyll-sitemap # generates /sitemap.xml for search engines
- jekyll-paginate # enables paginator in index.html
- jekyll-seo-tag # {% seo %} tag generates all meta/OG tags
- jekyll-toc # {% toc %} tag in post layouts
- jekyll-scholar # {% bibliography %} tag for BibTeX references
# ──────────────────────────────────────────────────
# 9. JEKYLL-SCHOLAR (academic references)
# Customize how citations render
# ──────────────────────────────────────────────────
scholar:
bibliography_template: bib-custom
citation_template: citation
backlink: "↩"
# style: apa # uncomment to use APA citation style
# locale: en
# link_citations: true
# ──────────────────────────────────────────────────
# 10. AUTHORS
# Used in post front matter as: author: ductri
# Each post byline is pulled from this block
# ──────────────────────────────────────────────────
authors:
ductri:
name: Duc-Tri
site: https://expred.co
avatar: "https://i.imgur.com/QSMPgex.jpg"
bio: "Researcher in advanced control systems, robotics, and industrial automation."
email: [email protected]
# ──────────────────────────────────────────────────
# 11. DEFAULTS
# Saves you from repeating front matter in every file.
# These are the fallback values if the file doesn't set them.
# ──────────────────────────────────────────────────
defaults:
# All posts in _posts/
- scope:
path: "_posts"
values:
layout: post-sidebar # default layout for posts
author: ductri # default author
lang: fr # default language for posts
toc: true
comments: true
# All pages in _pages/
- scope:
path: "_pages"
values:
layout: page
# Course lessons in _courses/
- scope:
path: "_courses"
type: "courses"
values:
layout: lesson
author: ductri
lang: fr
# ──────────────────────────────────────────────────
# 12. COLLECTIONS (for courses)
# ──────────────────────────────────────────────────
collections:
courses:
output: true
permalink: /courses/:path/
# ──────────────────────────────────────────────────
# 13. EXCLUDE FROM BUILD
# Files Jekyll should not copy to _site/
# ──────────────────────────────────────────────────
exclude:
- README.md
- Gemfile
- Gemfile.lock
- docker-compose.yml
- run.sh
- Makefile
- "*.sh"
- node_modules
- vendor
# ──────────────────────────────────────────────────
# 14. COMMENTS (Disqus — optional)
# Uncomment and add your Disqus shortname to enable.
# Controls whether comments appear on posts.
# ──────────────────────────────────────────────────
# disqus: your-disqus-shortname
# ──────────────────────────────────────────────────
# 15. NEWSLETTER (Mailchimp — optional)
# Uncomment and paste your Mailchimp form action URL
# to show a subscribe form at the bottom of each post.
# ──────────────────────────────────────────────────
# mailchimp-list: 'https://yourlist.us11.list-manage.com/...'
What to edit for common tasks
| You want to… | Edit this in _config.yml |
|---|---|
| Change the site name in navbar | name: |
| Change the site description for SEO | description: |
| Update your job title on resume | job: |
| Add/remove social icons on resume | Comment/uncomment the linkedin_username, github_username, etc. lines |
| Add a new author for guest posts | Add a block under authors:, use the key in post front matter |
| Change posts per page | paginate: (default 10) |
| Make TOC deeper/shallower | toc.max_level: |
| Enable Disqus comments | Uncomment disqus: line |
| Enable Mailchimp form | Uncomment mailchimp-list: line |
| Change default post layout | Under defaults: - scope: path: _posts, change layout: |
| Change the default language | default_lang: |
| Add a new language | Add to languages:, create _data/i18n/xx.yml |
| Change post URL format | permalink: — options: /:year/:month/:day/:title/, /:categories/:title/, /:title/ |
How _data/*.yml maps to the resume page
Your resume content is driven entirely by YAML files in _data/. The listing-order key controls the display order (lower number = first). Here's the map:
| File | Controls | listing-order |
|---|---|---|
_data/Experience.yml | Work experience section | 1 |
_data/Education.yml | Education section | 2 |
_data/Skills.yml | Skills section | 3 |
_data/Publications.yml | Publications section | 4 |
_data/Projects.yml | Projects section | 5–6 |
_data/Awards.yml | Awards section | 7 |
_data/Certifications.yml | Certifications section | 8 |
_data/Languages.yml | Language skills section | 9 |
To add a new section, create a new YAML file like _data/OpenSource.yml with listing-order: 6 and the same structure as the others. It will automatically appear in the resume.
To reorder sections, just change the listing-order numbers. To hide a section temporarily, add listing-order: 99 or delete the file.
Final Checklist
Part 1 — Design Tokens
- Create
assets/css/tokens.csswith all CSS variables + dark mode block - Replace hardcoded font families in
theme.cssandstyle.scss - Add dark mode toggle logic to
assets/js/theme.js - Update Google Fonts link to combined URL with
display=swap
Part 2 — Layout Unification
- Update
_layouts/default.html— add inline FOUC script, load tokens.css first, fix MathJax, fix rawgit CDN - Replace
_layouts/resume-layout.htmlentirely with thelayout: defaultversion - Migrate hardcoded colors in
style.scssto CSS variables - Update navbar in
default.htmlto usevar(--navbar-bg) - Add dark mode button to
menu-header.html - Fix duplicate
id="navbarDropdown"(from Phase 1 tutorial)
Part 3 — i18n
- Create
_data/i18n/fr.yml,en.yml,vi.yml - Add
default_lang: frandlanguages: [fr, en, vi]to_config.yml - Add
{%- assign t = site.data.i18n[page.lang | default: site.default_lang] -%}todefault.html,post.html,post-sidebar.html - Replace hardcoded UI strings with
{{ t.key }} - Create
_includes/lang-switcher.html - Add
lang: frto existing posts' front matter defaults in_config.yml
Part 4 — _config.yml
- Replace
_config.ymlwith the fully annotated version above - Rename
Gemfile_.lock→Gemfile.lockand pin gem versions - Add
lang: frunder the_postsdefaults block - Add
_coursescollection block - Update exclude list to remove build artifacts
1. Create
tokens.css and load it in default.html — this already makes dark mode work on the blog side.2. Fix the resume layout (replace the file) — this unifies the HTML shell.
3. Migrate colors in
style.scss — this makes dark mode work on the resume too.4. Add i18n files and the translation lookup — the switcher can come last.
After step 2, you can verify dark mode works on both pages by adding
data-theme="dark" manually to the <html> tag in DevTools.