mirror of
https://github.com/svemagie/blog-eleventy-indiekit.git
synced 2026-04-02 16:44:56 +02:00
feat: add configurable CV page layout with builder support
CV page now reads layout config from cv-page.json when available, supporting single-column, two-column, and full-width hero layouts with configurable sections, sidebar widgets, and footer columns. Falls back to the previous hardcoded layout when no config exists.
This commit is contained in:
29
_data/cvPageConfig.js
Normal file
29
_data/cvPageConfig.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* CV Page Configuration Data
|
||||||
|
* Reads config from indiekit-endpoint-cv plugin CV page builder.
|
||||||
|
* Falls back to null — cv.njk then uses the default hardcoded layout.
|
||||||
|
*
|
||||||
|
* The CV plugin writes a .indiekit/cv-page.json file that Eleventy watches.
|
||||||
|
* On change, a rebuild picks up the new config, allowing layout changes
|
||||||
|
* without a Docker rebuild.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { resolve, dirname } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
export default function () {
|
||||||
|
try {
|
||||||
|
// Resolve via the content/ symlink relative to the Eleventy project
|
||||||
|
const configPath = resolve(__dirname, "..", "content", ".indiekit", "cv-page.json");
|
||||||
|
const raw = readFileSync(configPath, "utf8");
|
||||||
|
const config = JSON.parse(raw);
|
||||||
|
console.log("[cvPageConfig] Loaded CV page builder config");
|
||||||
|
return config;
|
||||||
|
} catch {
|
||||||
|
// No CV page builder config — fall back to hardcoded layout in cv.njk
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
121
_includes/components/cv-builder.njk
Normal file
121
_includes/components/cv-builder.njk
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
{#
|
||||||
|
CV Page Builder - renders configured layout, sections, and sidebar
|
||||||
|
from cvPageConfig (written by indiekit-endpoint-cv plugin)
|
||||||
|
#}
|
||||||
|
|
||||||
|
{% set layout = cvPageConfig.layout or "single-column" %}
|
||||||
|
{% set hasSidebar = cvPageConfig.sidebar and cvPageConfig.sidebar.length %}
|
||||||
|
|
||||||
|
{# Hero — rendered at top when enabled (default: true) #}
|
||||||
|
{% if cvPageConfig.hero.enabled != false %}
|
||||||
|
<section class="mb-8 sm:mb-12">
|
||||||
|
<div class="flex flex-col sm:flex-row gap-6 sm:gap-8 items-start">
|
||||||
|
<img
|
||||||
|
src="{{ site.author.avatar }}"
|
||||||
|
alt="{{ site.author.name }}"
|
||||||
|
class="w-24 h-24 sm:w-32 sm:h-32 rounded-full object-cover shadow-lg flex-shrink-0"
|
||||||
|
loading="eager"
|
||||||
|
>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<h1 class="text-2xl sm:text-3xl md:text-4xl font-bold text-surface-900 dark:text-surface-100 mb-2">
|
||||||
|
{{ site.author.name }}
|
||||||
|
</h1>
|
||||||
|
{% if site.author.title %}
|
||||||
|
<p class="text-lg sm:text-xl text-primary-600 dark:text-primary-400 mb-3 sm:mb-4">
|
||||||
|
{{ site.author.title }}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
{% if site.author.bio %}
|
||||||
|
<p class="text-base sm:text-lg text-surface-700 dark:text-surface-300 mb-4">
|
||||||
|
{{ site.author.bio }}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
{% if cvPageConfig.hero.showSocial != false %}
|
||||||
|
<div class="flex flex-wrap gap-3">
|
||||||
|
{% for link in site.social %}
|
||||||
|
<a
|
||||||
|
href="{{ link.url }}"
|
||||||
|
rel="{{ link.rel }} noopener"
|
||||||
|
class="inline-flex items-center gap-2 px-3 py-2 text-sm bg-surface-100 dark:bg-surface-800 rounded-lg hover:bg-surface-200 dark:hover:bg-surface-700 transition-colors"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
<span class="text-sm font-medium">{{ link.name }}</span>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# Layout wrapper #}
|
||||||
|
{% if layout == "single-column" %}
|
||||||
|
|
||||||
|
{# Single column — no sidebar, full width sections #}
|
||||||
|
<div class="cv-sections">
|
||||||
|
{% for section in cvPageConfig.sections %}
|
||||||
|
{% include "components/homepage-section.njk" %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% elif layout == "two-column" and hasSidebar %}
|
||||||
|
|
||||||
|
{# Two column — sections + sidebar #}
|
||||||
|
<div class="layout-with-sidebar">
|
||||||
|
<div class="main-content">
|
||||||
|
<div class="cv-sections">
|
||||||
|
{% for section in cvPageConfig.sections %}
|
||||||
|
{% include "components/homepage-section.njk" %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<aside class="sidebar" data-pagefind-ignore>
|
||||||
|
{% include "components/cv-sidebar.njk" %}
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% elif layout == "full-width-hero" %}
|
||||||
|
|
||||||
|
{# Full width hero (already rendered above), then two-column below #}
|
||||||
|
{% if hasSidebar %}
|
||||||
|
<div class="layout-with-sidebar">
|
||||||
|
<div class="main-content">
|
||||||
|
<div class="cv-sections">
|
||||||
|
{% for section in cvPageConfig.sections %}
|
||||||
|
{% include "components/homepage-section.njk" %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<aside class="sidebar" data-pagefind-ignore>
|
||||||
|
{% include "components/cv-sidebar.njk" %}
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="cv-sections">
|
||||||
|
{% for section in cvPageConfig.sections %}
|
||||||
|
{% include "components/homepage-section.njk" %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
|
||||||
|
{# Fallback — two-column without sidebar, or unknown layout #}
|
||||||
|
<div class="cv-sections">
|
||||||
|
{% for section in cvPageConfig.sections %}
|
||||||
|
{% include "components/homepage-section.njk" %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# Last Updated #}
|
||||||
|
{% if cv.lastUpdated %}
|
||||||
|
<p class="text-sm text-surface-500 text-center mt-8">
|
||||||
|
Last updated: {{ cv.lastUpdated | date("PPP") }}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# Footer — rendered after the main layout, full width #}
|
||||||
|
{% include "components/cv-footer.njk" %}
|
||||||
26
_includes/components/cv-footer.njk
Normal file
26
_includes/components/cv-footer.njk
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{# CV Page Builder Footer — renders footer items in a responsive 3-column grid #}
|
||||||
|
{% if cvPageConfig.footer and cvPageConfig.footer.length %}
|
||||||
|
<footer class="cv-footer mt-8 sm:mt-12 pt-6 sm:pt-8 border-t border-surface-200 dark:border-surface-700">
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{% for section in cvPageConfig.footer %}
|
||||||
|
{% if section.type == "custom-html" %}
|
||||||
|
{% set sectionConfig = section.config or {} %}
|
||||||
|
<div>
|
||||||
|
{% if sectionConfig.title %}
|
||||||
|
<h3 class="text-lg font-semibold text-surface-900 dark:text-surface-100 mb-3">{{ sectionConfig.title }}</h3>
|
||||||
|
{% endif %}
|
||||||
|
{% if sectionConfig.content %}
|
||||||
|
<div class="prose dark:prose-invert prose-sm max-w-none">
|
||||||
|
{{ sectionConfig.content | safe }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div>
|
||||||
|
{% include "components/homepage-section.njk" %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
{% endif %}
|
||||||
45
_includes/components/cv-sidebar.njk
Normal file
45
_includes/components/cv-sidebar.njk
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
{# CV Page Builder Sidebar — renders widgets from cvPageConfig.sidebar #}
|
||||||
|
{% if cvPageConfig.sidebar and cvPageConfig.sidebar.length %}
|
||||||
|
{% for widget in cvPageConfig.sidebar %}
|
||||||
|
{% if widget.type == "author-card" %}
|
||||||
|
{% include "components/widgets/author-card.njk" %}
|
||||||
|
{% elif widget.type == "social-activity" %}
|
||||||
|
{% include "components/widgets/social-activity.njk" %}
|
||||||
|
{% elif widget.type == "github-repos" %}
|
||||||
|
{% include "components/widgets/github-repos.njk" %}
|
||||||
|
{% elif widget.type == "funkwhale" %}
|
||||||
|
{% include "components/widgets/funkwhale.njk" %}
|
||||||
|
{% elif widget.type == "recent-posts" %}
|
||||||
|
{% include "components/widgets/recent-posts.njk" %}
|
||||||
|
{% elif widget.type == "blogroll" %}
|
||||||
|
{% include "components/widgets/blogroll.njk" %}
|
||||||
|
{% elif widget.type == "feedland" %}
|
||||||
|
{% include "components/widgets/feedland.njk" %}
|
||||||
|
{% elif widget.type == "categories" %}
|
||||||
|
{% include "components/widgets/categories.njk" %}
|
||||||
|
{% elif widget.type == "search" %}
|
||||||
|
<div class="sidebar-widget">
|
||||||
|
<h3 class="text-sm font-semibold text-surface-600 dark:text-surface-400 uppercase tracking-wide mb-3">Search</h3>
|
||||||
|
<div id="sidebar-search"></div>
|
||||||
|
<script>initPagefind("#sidebar-search");</script>
|
||||||
|
</div>
|
||||||
|
{% elif widget.type == "webmentions" %}
|
||||||
|
{% include "components/widgets/webmentions.njk" %}
|
||||||
|
{% elif widget.type == "custom-html" %}
|
||||||
|
{# Custom content widget #}
|
||||||
|
{% set wConfig = widget.config or {} %}
|
||||||
|
<div class="sidebar-widget">
|
||||||
|
{% if wConfig.title %}
|
||||||
|
<h3 class="text-sm font-semibold text-surface-600 dark:text-surface-400 uppercase tracking-wide mb-3">{{ wConfig.title }}</h3>
|
||||||
|
{% endif %}
|
||||||
|
{% if wConfig.content %}
|
||||||
|
<div class="prose dark:prose-invert prose-sm max-w-none">
|
||||||
|
{{ wConfig.content | safe }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<!-- Unknown widget type: {{ widget.type }} -->
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
131
cv.njk
131
cv.njk
@@ -6,7 +6,7 @@ permalink: /cv/
|
|||||||
pagefindIgnore: true
|
pagefindIgnore: true
|
||||||
---
|
---
|
||||||
|
|
||||||
{# Standalone CV page — renders all CV sections using the same partials as the homepage builder #}
|
{# CV page — uses configurable layout when cvPageConfig exists, falls back to hardcoded layout #}
|
||||||
|
|
||||||
{% set hasCvData = (cv.experience and cv.experience.length) or
|
{% set hasCvData = (cv.experience and cv.experience.length) or
|
||||||
(cv.projects and cv.projects.length) or
|
(cv.projects and cv.projects.length) or
|
||||||
@@ -14,75 +14,84 @@ pagefindIgnore: true
|
|||||||
|
|
||||||
{% if hasCvData %}
|
{% if hasCvData %}
|
||||||
|
|
||||||
{# Hero / intro #}
|
{# Configurable layout — use cvPageConfig if available #}
|
||||||
<section class="mb-8 sm:mb-12">
|
{% if cvPageConfig and cvPageConfig.sections %}
|
||||||
<div class="flex flex-col sm:flex-row gap-6 sm:gap-8 items-start">
|
{% include "components/cv-builder.njk" %}
|
||||||
<img
|
|
||||||
src="{{ site.author.avatar }}"
|
{# Fallback — hardcoded layout for backward compatibility #}
|
||||||
alt="{{ site.author.name }}"
|
{% else %}
|
||||||
class="w-24 h-24 sm:w-32 sm:h-32 rounded-full object-cover shadow-lg flex-shrink-0"
|
|
||||||
loading="eager"
|
{# Hero / intro #}
|
||||||
>
|
<section class="mb-8 sm:mb-12">
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex flex-col sm:flex-row gap-6 sm:gap-8 items-start">
|
||||||
<h1 class="text-2xl sm:text-3xl md:text-4xl font-bold text-surface-900 dark:text-surface-100 mb-2">
|
<img
|
||||||
{{ site.author.name }}
|
src="{{ site.author.avatar }}"
|
||||||
</h1>
|
alt="{{ site.author.name }}"
|
||||||
{% if site.author.title %}
|
class="w-24 h-24 sm:w-32 sm:h-32 rounded-full object-cover shadow-lg flex-shrink-0"
|
||||||
<p class="text-lg sm:text-xl text-primary-600 dark:text-primary-400 mb-3 sm:mb-4">
|
loading="eager"
|
||||||
{{ site.author.title }}
|
|
||||||
</p>
|
|
||||||
{% endif %}
|
|
||||||
{% if site.author.bio %}
|
|
||||||
<p class="text-base sm:text-lg text-surface-700 dark:text-surface-300 mb-4">
|
|
||||||
{{ site.author.bio }}
|
|
||||||
</p>
|
|
||||||
{% endif %}
|
|
||||||
<div class="flex flex-wrap gap-3">
|
|
||||||
{% for link in site.social %}
|
|
||||||
<a
|
|
||||||
href="{{ link.url }}"
|
|
||||||
rel="{{ link.rel }} noopener"
|
|
||||||
class="inline-flex items-center gap-2 px-3 py-2 text-sm bg-surface-100 dark:bg-surface-800 rounded-lg hover:bg-surface-200 dark:hover:bg-surface-700 transition-colors"
|
|
||||||
target="_blank"
|
|
||||||
>
|
>
|
||||||
<span class="text-sm font-medium">{{ link.name }}</span>
|
<div class="flex-1 min-w-0">
|
||||||
</a>
|
<h1 class="text-2xl sm:text-3xl md:text-4xl font-bold text-surface-900 dark:text-surface-100 mb-2">
|
||||||
{% endfor %}
|
{{ site.author.name }}
|
||||||
|
</h1>
|
||||||
|
{% if site.author.title %}
|
||||||
|
<p class="text-lg sm:text-xl text-primary-600 dark:text-primary-400 mb-3 sm:mb-4">
|
||||||
|
{{ site.author.title }}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
{% if site.author.bio %}
|
||||||
|
<p class="text-base sm:text-lg text-surface-700 dark:text-surface-300 mb-4">
|
||||||
|
{{ site.author.bio }}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
<div class="flex flex-wrap gap-3">
|
||||||
|
{% for link in site.social %}
|
||||||
|
<a
|
||||||
|
href="{{ link.url }}"
|
||||||
|
rel="{{ link.rel }} noopener"
|
||||||
|
class="inline-flex items-center gap-2 px-3 py-2 text-sm bg-surface-100 dark:bg-surface-800 rounded-lg hover:bg-surface-200 dark:hover:bg-surface-700 transition-colors"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
<span class="text-sm font-medium">{{ link.name }}</span>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{# Experience — work-only variant #}
|
{# Experience — work-only variant #}
|
||||||
{% set section = { type: "cv-experience-work", config: {} } %}
|
{% set section = { type: "cv-experience-work", config: {} } %}
|
||||||
{% include "components/sections/cv-experience-work.njk" ignore missing %}
|
{% include "components/sections/cv-experience-work.njk" ignore missing %}
|
||||||
|
|
||||||
{# Skills — work-only variant #}
|
{# Skills — work-only variant #}
|
||||||
{% set section = { type: "cv-skills-work", config: {} } %}
|
{% set section = { type: "cv-skills-work", config: {} } %}
|
||||||
{% include "components/sections/cv-skills-work.njk" ignore missing %}
|
{% include "components/sections/cv-skills-work.njk" ignore missing %}
|
||||||
|
|
||||||
{# Work Projects (only work-related projects on the CV page) #}
|
{# Work Projects (only work-related projects on the CV page) #}
|
||||||
{% set section = { type: "cv-projects-work", config: {} } %}
|
{% set section = { type: "cv-projects-work", config: {} } %}
|
||||||
{% include "components/sections/cv-projects-work.njk" ignore missing %}
|
{% include "components/sections/cv-projects-work.njk" ignore missing %}
|
||||||
|
|
||||||
{# Education — work-only variant #}
|
{# Education — work-only variant #}
|
||||||
{% set section = { type: "cv-education-work", config: {} } %}
|
{% set section = { type: "cv-education-work", config: {} } %}
|
||||||
{% include "components/sections/cv-education-work.njk" ignore missing %}
|
{% include "components/sections/cv-education-work.njk" ignore missing %}
|
||||||
|
|
||||||
{# Languages — standalone section #}
|
{# Languages — standalone section #}
|
||||||
{% set section = { type: "cv-languages", config: {} } %}
|
{% set section = { type: "cv-languages", config: {} } %}
|
||||||
{% include "components/sections/cv-languages.njk" ignore missing %}
|
{% include "components/sections/cv-languages.njk" ignore missing %}
|
||||||
|
|
||||||
{# Interests — work-only variant #}
|
{# Interests — work-only variant #}
|
||||||
{% set section = { type: "cv-interests-work", config: {} } %}
|
{% set section = { type: "cv-interests-work", config: {} } %}
|
||||||
{% include "components/sections/cv-interests-work.njk" ignore missing %}
|
{% include "components/sections/cv-interests-work.njk" ignore missing %}
|
||||||
|
|
||||||
{# Last Updated #}
|
{# Last Updated #}
|
||||||
{% if cv.lastUpdated %}
|
{% if cv.lastUpdated %}
|
||||||
<p class="text-sm text-surface-500 text-center mt-8">
|
<p class="text-sm text-surface-500 text-center mt-8">
|
||||||
Last updated: {% if cv.lastUpdated %}{{ cv.lastUpdated | date("PPP") }}{% endif %}
|
Last updated: {{ cv.lastUpdated | date("PPP") }}
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user