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.

Jekyll 4 CSS Custom Properties No extra plugins localStorage dark mode FR / EN / VI

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:

PropertyBlog/Tutorial (Mundana)Resume (jekyll-resume)
Background#fff pure white#fffdfb warm ivory
Primary accent#03a87c teal-green#7FA093 muted sage
Font familyLora + PoppinsPoppins only
HeadingsGeorgia serifPoppins sans-serif
Link colorteal #03a87cdark sage #3D725D
Layout systemBootstrap 4 gridCustom CSS grid + fixed sidebar
Dark modeNoneNone
MultilingualNoneNone

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

Step 1.1

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; }
How it works: Every color reference in both themes is replaced by a CSS variable. When you toggle 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.
Step 1.2

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');
      }
    });
})();
FOUC prevention: This script must run before the first paint. Place <script src=".../theme.js"></script> in the <head> of default.htmlbefore 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' ? '☀' : '☽';
  }
});
Step 1.3

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:

assets/css/theme.css — hardcoded font families
- .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:

assets/css/style.scss — body font
- 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:

_layouts/default.html — Google Fonts
- <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">
The combined URL loads both fonts in one request instead of two. The 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

Step 2.1

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>
Step 2.2

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>
The only three differences from the original: (1) 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:

assets/css/style.scss — color variable migration
/* 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)

Step 3.1

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
Blog post content itself (the Markdown) is not auto-translated — you write separate posts for each language. But the surrounding UI (navbar, "read more", "published on", etc.) will switch language automatically.
Step 3.2

Create _data/i18n/ files

_data/
  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ủ"
Step 3.3

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]
Jekyll automatically loads _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.
Step 3.4

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]
---
Step 3.5

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>
Language routing strategy: The simplest approach for your site is: French content stays at / (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

Overview

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/...'
Quick Ref

What to edit for common tasks

You want to…Edit this in _config.yml
Change the site name in navbarname:
Change the site description for SEOdescription:
Update your job title on resumejob:
Add/remove social icons on resumeComment/uncomment the linkedin_username, github_username, etc. lines
Add a new author for guest postsAdd a block under authors:, use the key in post front matter
Change posts per pagepaginate: (default 10)
Make TOC deeper/shallowertoc.max_level:
Enable Disqus commentsUncomment disqus: line
Enable Mailchimp formUncomment mailchimp-list: line
Change default post layoutUnder defaults: - scope: path: _posts, change layout:
Change the default languagedefault_lang:
Add a new languageAdd to languages:, create _data/i18n/xx.yml
Change post URL formatpermalink: — options: /:year/:month/:day/:title/, /:categories/:title/, /:title/
Quick Ref

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:

FileControlslisting-order
_data/Experience.ymlWork experience section1
_data/Education.ymlEducation section2
_data/Skills.ymlSkills section3
_data/Publications.ymlPublications section4
_data/Projects.ymlProjects section5–6
_data/Awards.ymlAwards section7
_data/Certifications.ymlCertifications section8
_data/Languages.ymlLanguage skills section9

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.css with all CSS variables + dark mode block
  • Replace hardcoded font families in theme.css and style.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.html entirely with the layout: default version
  • Migrate hardcoded colors in style.scss to CSS variables
  • Update navbar in default.html to use var(--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: fr and languages: [fr, en, vi] to _config.yml
  • Add {%- assign t = site.data.i18n[page.lang | default: site.default_lang] -%} to default.html, post.html, post-sidebar.html
  • Replace hardcoded UI strings with {{ t.key }}
  • Create _includes/lang-switcher.html
  • Add lang: fr to existing posts' front matter defaults in _config.yml

Part 4 — _config.yml

  • Replace _config.yml with the fully annotated version above
  • Rename Gemfile_.lockGemfile.lock and pin gem versions
  • Add lang: fr under the _posts defaults block
  • Add _courses collection block
  • Update exclude list to remove build artifacts
Recommended order of implementation:
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.