Greetings, intrepid static site architect! You stand before the great Hall of Collections—a mystical chamber where scattered content transforms into organized, filterable, and dynamic displays. In this quest, you shall master the ancient arts of Jekyll collections, forging powerful layouts that respond to frontmatter incantations and user interactions alike.
The knowledge you gain here will allow you to build quest tracking systems, portfolio galleries, documentation hubs, and any content collection that demands organization and discovery. Whether you seek to catalog your own adventures or create portals for fellow travelers, this quest will arm you with the spells needed to succeed.
In the realm of static site generators, Jekyll reigns as the venerable wizard-king—simple yet powerful, transforming markdown into magnificent web pages. But many who wield Jekyll’s power never venture beyond basic posts and pages, missing the treasure trove of collections.
Collections are Jekyll’s secret weapon for organizing related content: quests, products, team members, portfolio pieces—anything that shares a common structure. Combined with Liquid templating and frontmatter metadata, collections become the foundation for dynamic, data-driven interfaces that rival server-rendered applications.
This quest was born from a real implementation: building a quest tracking system for IT-Journey that displays quests by tier, allows filtering by type/difficulty/technology, shows statistics, and supports level-specific views. You will recreate this system, learning production-ready patterns along the way.
By the time you complete this epic journey, you will have mastered:
quest-collection.html layout with tier-based groupingYou’ll know you’ve truly mastered this quest when you can:
--trace and strategic output_config.yml, _layouts/, _includes/)This 🔴 Hard quest expects:
Different platforms offer unique advantages for this quest. Choose the path that best fits your current setup and learning goals.
# Start Jekyll development environment
docker-compose up -d
# Verify Jekyll is running
docker-compose exec jekyll bundle exec jekyll --version
# Build site with trace for debugging
docker-compose exec jekyll bundle exec jekyll build --trace
# Serve with live reload
docker-compose exec jekyll bundle exec jekyll serve --host 0.0.0.0 --port 4000 --livereload
Docker ensures consistent Ruby/Jekyll versions across all team members and CI/CD pipelines. This is the path of the wise.
# Install Jekyll via Homebrew
brew install ruby
gem install bundler jekyll
# Navigate to project
cd ~/github/it-journey
# Install dependencies
bundle install
# Build and serve
bundle exec jekyll serve --port 4000 --livereload
macOS provides a native development experience. Ensure you’re using a Ruby version manager (rbenv or rvm) to avoid permission issues.
# Using WSL2 (recommended) or RubyInstaller
wsl --install # If WSL not installed
# In WSL terminal
sudo apt update
sudo apt install ruby-full build-essential zlib1g-dev
gem install bundler jekyll
# Navigate and serve
cd /mnt/c/Users/YourName/github/it-journey
bundle install
bundle exec jekyll serve --port 4000
WSL2 provides a Linux environment within Windows, making Jekyll development smooth.
# Install Ruby and Jekyll (Ubuntu/Debian)
sudo apt update
sudo apt install ruby-full build-essential zlib1g-dev
gem install bundler jekyll
# Navigate to project
cd ~/github/it-journey
# Install and serve
bundle install
bundle exec jekyll serve --port 4000 --livereload
Linux is Jekyll’s native habitat. Most commands work identically to macOS.
Before we forge our quest tracking system, we must understand the mystical nature of Jekyll collections—repositories of related content that share structure and purpose.
site.collections and site.<collection_name>Open your _config.yml and examine (or add) the collections configuration:
# _config.yml
# Collections configuration
collections_dir: pages # Optional: group collections in a subdirectory
collections:
quests:
output: true # Generate individual pages for each quest
permalink: /:collection/:categories/:name/
sort_by: level # Optional: default sort order
# Default frontmatter for quests
defaults:
- scope:
path: ""
type: quests
values:
layout: journals # Default layout for quest pages
fmContentType: quest
Key Configuration Options:
output: true - Generates individual HTML pages for each documentpermalink - URL structure for collection itemssort_by - Default sorting (overridable in templates)pages/
└── _quests/
├── README.md # Collection index
├── templates/ # Quest templates
│ └── main-quest-template.md
├── 0000/ # Level 0000 quests
│ ├── README.md # Level index
│ └── hello-world.md
├── 0001/ # Level 0001 quests
│ └── ...
└── 0101/ # Level 0101 quests (your current level!)
├── README.md
└── jekyll-quest-tracking.md # This quest!
{%- comment -%} All quests in the collection {%- endcomment -%}
{% assign all_quests = site.quests %}
{%- comment -%} Filter quests with a specific attribute {%- endcomment -%}
{% assign hard_quests = site.quests | where: "difficulty", "🔴 Hard" %}
{%- comment -%} Filter using expressions {%- endcomment -%}
{% assign level_0101 = site.quests | where: "level", "0101" %}
{%- comment -%} Map to extract specific values {%- endcomment -%}
{% assign all_levels = site.quests | map: "level" | compact | uniq | sort %}
{%- comment -%} Count quests {%- endcomment -%}
{% assign quest_count = site.quests | size %}
output: true is essential for individual quest pages?achievements) to your site?_config.yml has quests collection configured_quests directory hierarchy180 in a templateNow we forge the heart of our system—a layout that transforms raw quest data into an organized, tier-based display.
defaultCreate _layouts/quest-collection.html:
---
layout: default
---
{%- comment -%}
Quest Collection Layout
Displays quests from the site.quests collection using frontmatter data.
Supports filtering by level (pass level parameter to filter to specific level).
{%- endcomment -%}
<div class="quest-collection">
<header class="quest-collection-header">
<h1>{{ page.title | default: "Quest Collection" }}</h1>
{% if page.description %}
<p class="lead">{{ page.description }}</p>
{% endif %}
</header>
{%- comment -%} Quest Statistics {%- endcomment -%}
{% include quest-stats.html level=page.level %}
{%- comment -%} Quest Filters {%- endcomment -%}
{% include quest-filters.html level=page.level %}
{%- comment -%}
Determine which quests to display:
- If page.level is set, filter to that level only
- Otherwise show all quests
{%- endcomment -%}
{% if page.level %}
{% assign filtered_quests = site.quests | where: "level", page.level %}
{% else %}
{% assign filtered_quests = site.quests %}
{% endif %}
{%- comment -%} Group quests by level tier {%- endcomment -%}
{% assign level_0000 = filtered_quests | where: "level", "0000" %}
{% assign level_0001 = filtered_quests | where: "level", "0001" %}
{% assign level_0010 = filtered_quests | where: "level", "0010" %}
{% assign level_0011 = filtered_quests | where: "level", "0011" %}
{%- comment -%} Continue for all 16 levels... {%- endcomment -%}
{%- comment -%} Apprentice Tier (0000-0011) {%- endcomment -%}
{% assign apprentice_quests = level_0000 | concat: level_0001 | concat: level_0010 | concat: level_0011 %}
{% if apprentice_quests.size > 0 and page.level == nil %}
<section class="quest-tier quest-tier-apprentice" data-tier="apprentice">
<h2 class="tier-header">
<span class="tier-icon">🌱</span>
<span class="tier-name">Apprentice Tier</span>
<span class="tier-levels">(Levels 0000-0011)</span>
<span class="tier-count">{{ apprentice_quests.size }} quests</span>
</h2>
<p class="tier-description">Foundation skills for beginning your IT journey.</p>
<div class="quest-grid">
{% for quest in apprentice_quests %}
{% include quest-card.html quest=quest %}
{% endfor %}
</div>
</section>
{% endif %}
{%- comment -%} Repeat for Adventurer, Warrior, Master tiers... {%- endcomment -%}
{%- comment -%} Page Content (for additional markdown content) {%- endcomment -%}
{% if content != "" %}
<section class="quest-collection-content">
{{ content }}
</section>
{% endif %}
</div>
/* Quest Tier Sections */
.quest-tier {
margin-bottom: 3rem;
padding: 1.5rem;
border-radius: 8px;
background: var(--bg-secondary, #f8f9fa);
}
/* Tier Colors */
.quest-tier-apprentice { border-left: 4px solid #28a745; } /* 🌱 Green */
.quest-tier-adventurer { border-left: 4px solid #fd7e14; } /* ⚔️ Orange */
.quest-tier-warrior { border-left: 4px solid #dc3545; } /* 🔥 Red */
.quest-tier-master { border-left: 4px solid #6f42c1; } /* 👑 Purple */
/* Quest Grid */
.quest-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 1.5rem;
}
level=page.level to the includes?| concat: work to combine arrays in Liquid?page.level == nil in conditionals?quest-collection.html exists in _layouts/Each quest deserves a beautifully crafted card that displays its vital statistics. We shall create a reusable include component.
Create _includes/quest-card.html:
{% comment %}
Quest Card Include
Usage: {% include quest-card.html quest=quest %}
Required frontmatter in quest pages:
- title, difficulty, estimated_time, quest_type, level, description
{% endcomment %}
{% assign quest = include.quest %}
<article class="quest-card"
data-difficulty="{{ quest.difficulty | default: 'Unknown' }}"
data-quest-type="{{ quest.quest_type | default: 'main_quest' }}"
data-level="{{ quest.level | default: '0000' }}"
data-technology="{{ quest.primary_technology | default: '' }}"
data-skill-focus="{{ quest.skill_focus | default: '' }}"
data-title="{{ quest.title | escape }}"
data-description="{{ quest.description | default: '' | strip_html | escape }}">
<div class="quest-card-header">
{% if quest.difficulty %}
{% assign diff = quest.difficulty | split: ' ' | first %}
<span class="quest-difficulty" title="{{ quest.difficulty }}">{{ diff }}</span>
{% endif %}
{% if quest.quest_type %}
{% case quest.quest_type %}
{% when 'main_quest' %}
<span class="quest-type quest-type-main" title="Main Quest">🏰</span>
{% when 'side_quest' %}
<span class="quest-type quest-type-side" title="Side Quest">⚔️</span>
{% when 'bonus_quest' %}
<span class="quest-type quest-type-bonus" title="Bonus Quest">🎁</span>
{% when 'epic_quest' %}
<span class="quest-type quest-type-epic" title="Epic Quest">👑</span>
{% endcase %}
{% endif %}
</div>
<h3 class="quest-card-title">
<a href="{{ quest.url | relative_url }}">{{ quest.title | default: 'Untitled Quest' }}</a>
</h3>
{% if quest.description %}
<p class="quest-card-description">{{ quest.description | truncate: 120 }}</p>
{% endif %}
<div class="quest-card-meta">
{% if quest.estimated_time %}
<span class="quest-time" title="Estimated Time">🕐 {{ quest.estimated_time }}</span>
{% endif %}
{% if quest.level %}
<span class="quest-level" title="Level {{ quest.level }}">📊 Lvl {{ quest.level }}</span>
{% endif %}
{% if quest.primary_technology %}
<span class="quest-tech" title="Primary Technology">🛠️ {{ quest.primary_technology }}</span>
{% endif %}
</div>
<a href="{{ quest.url | relative_url }}" class="quest-card-link">Begin Quest →</a>
</article>
Data attributes (data-*) are the bridge between server-rendered HTML and client-side JavaScript:
<article class="quest-card"
data-difficulty="🔴 Hard"
data-quest-type="side_quest"
data-level="0101"
data-technology="jekyll">
JavaScript can then read these attributes:
const card = document.querySelector('.quest-card');
console.log(card.dataset.difficulty); // "🔴 Hard"
console.log(card.dataset.questType); // "side_quest" (camelCase conversion)
| escape when outputting to data attributes?include.quest and quest?quest-card.html exists in _includes/Now we weave JavaScript magic to enable real-time filtering of our quest collection.
Create _includes/quest-filters.html:
{%- comment -%}
Quest Filters Include
Provides interactive filtering UI for quest collections.
{%- endcomment -%}
{%- comment -%} Collect unique values for filter dropdowns {%- endcomment -%}
{% assign all_quests = site.quests | where_exp: "q", "q.title != nil" %}
{%- if include.level and include.level != '' -%}
{% assign all_quests = all_quests | where: "level", include.level %}
{%- endif -%}
{%- comment -%} Get unique quest types {%- endcomment -%}
{% assign quest_types = all_quests | map: "quest_type" | compact | uniq | sort %}
{%- comment -%} Get unique difficulties {%- endcomment -%}
{% assign difficulties = all_quests | map: "difficulty" | compact | uniq | sort %}
{%- comment -%}
IMPORTANT: Level sorting edge case fix!
Some frontmatter has level: 1100 (Integer), others level: "1100" (String).
Liquid's sort filter fails on mixed types. We coerce all to strings.
{%- endcomment -%}
{% assign levels_raw = all_quests | map: "level" | compact | uniq %}
{% assign levels_joined = "" %}
{% for lval in levels_raw %}
{% assign levels_joined = levels_joined | append: lval | append: "," %}
{% endfor %}
{% assign levels = levels_joined | split: "," | uniq | sort %}
<div class="quest-filters" id="quest-filters">
<div class="filters-header">
<h3>🔍 Filter Quests</h3>
<button type="button" class="btn-reset-filters" onclick="resetAllFilters()">
Reset All
</button>
</div>
<div class="filters-grid">
{%- comment -%} Quest Type Filter {%- endcomment -%}
<div class="filter-group">
<label for="filter-quest-type">Quest Type</label>
<select id="filter-quest-type" onchange="applyFilters()">
<option value="">All Types</option>
{% for type in quest_types %}
<option value="{{ type }}">
{% case type %}
{% when 'main_quest' %}🏰 Main Quest
{% when 'side_quest' %}⚔️ Side Quest
{% when 'bonus_quest' %}🎁 Bonus Quest
{% when 'epic_quest' %}👑 Epic Quest
{% else %}{{ type | replace: "_", " " | capitalize }}
{% endcase %}
</option>
{% endfor %}
</select>
</div>
{%- comment -%} Additional filters: difficulty, level, technology, skill_focus, search {%- endcomment -%}
{%- comment -%} ... (similar pattern for each filter) ... {%- endcomment -%}
</div>
</div>
<script>
function applyFilters() {
const questType = document.getElementById('filter-quest-type').value.toLowerCase();
const difficulty = document.getElementById('filter-difficulty').value.toLowerCase();
const level = document.getElementById('filter-level').value.toLowerCase();
const technology = document.getElementById('filter-technology').value.toLowerCase();
const skillFocus = document.getElementById('filter-skill-focus').value.toLowerCase();
const searchTerm = document.getElementById('filter-search').value.toLowerCase().trim();
const questCards = document.querySelectorAll('.quest-card');
let visibleCount = 0;
questCards.forEach(card => {
const cardType = (card.dataset.questType || '').toLowerCase();
const cardDifficulty = (card.dataset.difficulty || '').toLowerCase();
const cardLevel = (card.dataset.level || '').toLowerCase();
const cardTechnology = (card.dataset.technology || '').toLowerCase();
const cardSkillFocus = (card.dataset.skillFocus || '').toLowerCase();
const cardTitle = (card.dataset.title || '').toLowerCase();
const cardDescription = (card.dataset.description || '').toLowerCase();
let show = true;
// Apply each filter
if (questType && !cardType.includes(questType)) show = false;
if (show && difficulty && !cardDifficulty.includes(difficulty)) show = false;
if (show && level && cardLevel !== level) show = false;
if (show && technology && !cardTechnology.includes(technology)) show = false;
if (show && skillFocus && !cardSkillFocus.includes(skillFocus)) show = false;
if (show && searchTerm) {
const matchesSearch = cardTitle.includes(searchTerm) ||
cardDescription.includes(searchTerm) ||
cardTechnology.includes(searchTerm);
if (!matchesSearch) show = false;
}
if (show) {
card.classList.remove('filtered-out');
visibleCount++;
} else {
card.classList.add('filtered-out');
}
});
// Update tier visibility
updateTierVisibility();
// Update results count
document.getElementById('results-count').textContent =
`Showing ${visibleCount} of ${questCards.length} quests`;
}
function updateTierVisibility() {
const tiers = document.querySelectorAll('.quest-tier');
tiers.forEach(tier => {
const visibleCards = tier.querySelectorAll('.quest-card:not(.filtered-out)');
tier.classList.toggle('filtered-empty', visibleCards.length === 0);
});
}
function resetAllFilters() {
document.getElementById('filter-quest-type').value = '';
document.getElementById('filter-difficulty').value = '';
document.getElementById('filter-level').value = '';
document.getElementById('filter-technology').value = '';
document.getElementById('filter-skill-focus').value = '';
document.getElementById('filter-search').value = '';
applyFilters();
}
// Initialize on page load
document.addEventListener('DOMContentLoaded', applyFilters);
</script>
The Problem: Jekyll’s Liquid will fail with comparison of Integer with String failed when sorting an array containing both 1100 (Integer) and "1100" (String).
The Solution: Coerce all values to strings using the join/split pattern:
{% assign levels_raw = all_quests | map: "level" | compact | uniq %}
{% assign levels_joined = "" %}
{% for lval in levels_raw %}
{% assign levels_joined = levels_joined | append: lval | append: "," %}
{% endfor %}
{% assign levels = levels_joined | split: "," | uniq | sort %}
This forces all values through string concatenation, ensuring uniform types.
.toLowerCase() when comparing filter values?card.classList.toggle('filtered-empty', condition) work?== instead of includes() for difficulty?Manual frontmatter updates are tedious and error-prone. We shall forge a Python spell to automate the task.
pathlibCreate scripts/development/update_level_readmes.py:
#!/usr/bin/env python3
"""
Script to ensure all level README files have consistent frontmatter:
- layout: quest-collection
- level: <directory_name>
- categories: quests
Only updates README.md files in direct child directories of pages/_quests
where the directory name is a 4-digit binary string (e.g., 0101, 1100).
"""
import os
import re
from pathlib import Path
# Calculate repo root: script is in scripts/development/
ROOT = Path(__file__).resolve().parents[2]
QUESTS_DIR = ROOT / 'pages' / '_quests'
# Regex to match binary level directories (0000-1111)
LEVEL_DIR_RE = re.compile(r'^[01]{4}$')
def process_readme(path: Path, level: str):
"""Update a README.md with required frontmatter fields."""
text = path.read_text(encoding='utf-8')
# Check for frontmatter
if not text.startswith('---'):
print(f"Skipping {path}: no frontmatter detected")
return
# Split into header and body
parts = text.split('---', 2)
header = parts[1]
body = parts[2] if len(parts) > 2 else ''
# Parse header into lines
lines = [line.rstrip('\n') for line in header.split('\n')]
# Check for required keys
has_layout = any(re.match(r'\s*layout\s*:', line) for line in lines)
has_level = any(re.match(r'\s*level\s*:', line) for line in lines)
has_categories = any(re.match(r'\s*categories\s*:', line) for line in lines)
# Append missing keys
new_lines = lines.copy()
appended = False
if not has_layout:
new_lines.append('layout: quest-collection')
appended = True
if not has_level:
new_lines.append(f'level: {level}')
appended = True
if not has_categories:
new_lines.append('categories: quests')
appended = True
# Write back if changes made
if appended:
new_header = '\n'.join(new_lines)
new_text = '---\n' + new_header + '\n---' + body
path.write_text(new_text, encoding='utf-8')
print(f"Updated frontmatter for {path}")
else:
print(f"No changes for {path}")
def main():
"""Process all level directories."""
if not QUESTS_DIR.exists():
print("Quests directory not found at:", QUESTS_DIR)
return
for entry in QUESTS_DIR.iterdir():
if entry.is_dir() and LEVEL_DIR_RE.match(entry.name):
readme = entry / 'README.md'
if readme.exists():
process_readme(readme, entry.name)
else:
print(f"Skipping {entry}: README.md not present")
if __name__ == '__main__':
main()
# From repo root with Python virtual environment
python scripts/development/update_level_readmes.py
# Or using the venv explicitly
.venv/bin/python scripts/development/update_level_readmes.py
Expected Output:
Updated frontmatter for /Users/you/github/it-journey/pages/_quests/0000/README.md
Updated frontmatter for /Users/you/github/it-journey/pages/_quests/0001/README.md
No changes for /Users/you/github/it-journey/pages/_quests/0101/README.md
Skipping /Users/you/github/it-journey/pages/_quests/1001: README.md not present
Path(__file__).resolve().parents[2] instead of a hardcoded path?---)?Goal: Add a “Learning Style” filter to the quest filters
Requirements:
learning_style (hands-on, conceptual, project-based)data-learning-style attribute to quest cardsapplyFilters() functionSuccess Criteria: Filtering by learning style works correctly
Estimated Time: 30 minutes
Goal: Add technology distribution to quest statistics
Requirements:
primary_technology in the stats includeSuccess Criteria: Stats show technology breakdown
Estimated Time: 45 minutes
Goal: Implement pagination for large quest collections
Requirements:
Success Criteria: Pagination improves page load and UX for large collections
Estimated Time: 1-2 hours
Goal: Add autocomplete suggestions to the search filter
Requirements:
Success Criteria: Professional-quality search autocomplete
Estimated Time: 2-3 hours
_layouts/quest-collection.html - Collection layout with tier grouping_includes/quest-card.html - Reusable quest card component_includes/quest-filters.html - Interactive filter UI with JavaScript_includes/quest-stats.html - Statistics display componentscripts/development/update_level_readmes.py - Frontmatter automationBuild the site and verify:
docker-compose exec jekyll bundle exec jekyll build --trace
Check for:
graph TB
subgraph Prerequisites
A[Level 0100: Frontend Docker] --> B[This Quest]
C[Level 0001: GitHub Pages] -.-> B
D[Level 0010: Terminal Enhancement] -.-> B
end
subgraph Current
B[Level 0101: Jekyll Quest Tracking]
end
subgraph Unlocks
B --> E[Level 1010: Automation & Testing]
B --> F[Level 1011: Feature Development]
B --> G[Level 1010: Hyperlink Guardian]
end
subgraph Parallel
B ~~~ H[Level 0101: Docker Mastery]
B ~~~ I[Level 0101: LazyTeX CV]
end
style B fill:#6f42c1,color:#fff
style A fill:#28a745,color:#fff
style E fill:#dc3545,color:#fff
style F fill:#dc3545,color:#fff
flowchart LR
subgraph Data Layer
A[Quest Markdown Files] --> B[YAML Frontmatter]
B --> C[site.quests Collection]
end
subgraph Template Layer
C --> D[quest-collection.html Layout]
D --> E[quest-stats.html Include]
D --> F[quest-filters.html Include]
D --> G[quest-card.html Include]
end
subgraph Output Layer
E --> H[Statistics Display]
F --> I[Filter Dropdowns]
G --> J[Quest Cards with Data Attrs]
end
subgraph Client Layer
I --> K[JavaScript applyFilters]
J --> K
K --> L[Filtered Quest Display]
end
subgraph Automation
M[Python Script] --> B
end
style A fill:#0d6efd,color:#fff
style D fill:#6f42c1,color:#fff
style K fill:#28a745,color:#fff
style M fill:#fd7e14,color:#fff
You have successfully forged the Jekyll Quest Tracking system! Your mastery of collections, Liquid templating, and client-side filtering has created a powerful, reusable system for organizing and discovering content.
Your newfound powers open several paths:
| Resource | Description |
|---|---|
| Jekyll Collections Docs | Official documentation |
| Liquid Reference | Complete Liquid syntax |
| CSS Grid Guide | Grid layout mastery |
| JavaScript Dataset API | Data attribute access |
This quest was developed through AI-assisted content generation:
| Phase | AI Contribution | Human Validation |
|---|---|---|
| Planning | Analyzed open commits to understand implementation | Confirmed scope and objectives |
| Structure | Generated quest outline following templates | Reviewed for accuracy |
| Code Examples | Created Liquid/JS/Python snippets | Tested in Jekyll environment |
| Diagrams | Generated Mermaid visualizations | Verified relationships |
Key AI Insights:
/pages/_quests/README.md with this quest link/pages/_quests/0101/README.md with quest entryBefore committing, verify:
May your collections be organized, your filters be fast, and your frontmatter be consistent! Ready for your next adventure? Check the Quest Map for your next challenge! ⚔️✨