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:
Ricardo
2026-02-20 15:17:32 +01:00
parent a54600b003
commit 66e55af7ee
5 changed files with 291 additions and 61 deletions

29
_data/cvPageConfig.js Normal file
View 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;
}
}

View 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" %}

View 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 %}

View 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
View File

@@ -6,7 +6,7 @@ permalink: /cv/
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
(cv.projects and cv.projects.length) or
@@ -14,75 +14,84 @@ pagefindIgnore: true
{% if hasCvData %}
{# Hero / intro #}
<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 %}
<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"
{# Configurable layout — use cvPageConfig if available #}
{% if cvPageConfig and cvPageConfig.sections %}
{% include "components/cv-builder.njk" %}
{# Fallback — hardcoded layout for backward compatibility #}
{% else %}
{# Hero / intro #}
<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"
>
<span class="text-sm font-medium">{{ link.name }}</span>
</a>
{% endfor %}
<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 %}
<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>
</section>
{# Experience — work-only variant #}
{% set section = { type: "cv-experience-work", config: {} } %}
{% include "components/sections/cv-experience-work.njk" ignore missing %}
{# Experience — work-only variant #}
{% set section = { type: "cv-experience-work", config: {} } %}
{% include "components/sections/cv-experience-work.njk" ignore missing %}
{# Skills — work-only variant #}
{% set section = { type: "cv-skills-work", config: {} } %}
{% include "components/sections/cv-skills-work.njk" ignore missing %}
{# Skills — work-only variant #}
{% set section = { type: "cv-skills-work", config: {} } %}
{% include "components/sections/cv-skills-work.njk" ignore missing %}
{# Work Projects (only work-related projects on the CV page) #}
{% set section = { type: "cv-projects-work", config: {} } %}
{% include "components/sections/cv-projects-work.njk" ignore missing %}
{# Work Projects (only work-related projects on the CV page) #}
{% set section = { type: "cv-projects-work", config: {} } %}
{% include "components/sections/cv-projects-work.njk" ignore missing %}
{# Education — work-only variant #}
{% set section = { type: "cv-education-work", config: {} } %}
{% include "components/sections/cv-education-work.njk" ignore missing %}
{# Education — work-only variant #}
{% set section = { type: "cv-education-work", config: {} } %}
{% include "components/sections/cv-education-work.njk" ignore missing %}
{# Languages — standalone section #}
{% set section = { type: "cv-languages", config: {} } %}
{% include "components/sections/cv-languages.njk" ignore missing %}
{# Languages — standalone section #}
{% set section = { type: "cv-languages", config: {} } %}
{% include "components/sections/cv-languages.njk" ignore missing %}
{# Interests — work-only variant #}
{% set section = { type: "cv-interests-work", config: {} } %}
{% include "components/sections/cv-interests-work.njk" ignore missing %}
{# Interests — work-only variant #}
{% set section = { type: "cv-interests-work", config: {} } %}
{% include "components/sections/cv-interests-work.njk" ignore missing %}
{# Last Updated #}
{% if cv.lastUpdated %}
<p class="text-sm text-surface-500 text-center mt-8">
Last updated: {% if cv.lastUpdated %}{{ cv.lastUpdated | date("PPP") }}{% endif %}
</p>
{% 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 %}
{% endif %}
{% else %}