feat: prefer identity data over env vars in hero and h-card

Hero and h-card templates now resolve homepageConfig.identity.*
fields first, falling back to site.author.* env vars. Includes
design doc and implementation plan for the identity editor.
This commit is contained in:
Ricardo
2026-02-25 08:55:16 +01:00
parent de043020ac
commit b317148cd1
4 changed files with 704 additions and 37 deletions

View File

@@ -4,83 +4,98 @@
This is the canonical h-card component for the site.
Include in sidebar widgets, author cards, etc.
#}
{% set id = homepageConfig.identity if (homepageConfig and homepageConfig.identity) else {} %}
{% set authorName = id.name or site.author.name %}
{% set authorAvatar = id.avatar or site.author.avatar %}
{% set authorTitle = id.title or site.author.title %}
{% set authorBio = id.bio or site.author.bio %}
{% set authorUrl = id.url or site.author.url %}
{% set authorPronoun = id.pronoun or site.author.pronoun %}
{% set authorLocality = id.locality or site.author.locality %}
{% set authorCountry = id.country or site.author.country %}
{% set authorLocation = site.author.location %}
{% set authorOrg = id.org or site.author.org %}
{% set authorEmail = id.email or site.author.email %}
{% set authorKeyUrl = id.keyUrl or site.author.keyUrl %}
{% set authorCategories = id.categories if (id.categories and id.categories.length) else site.author.categories %}
{% set socialLinks = id.social if (id.social and id.social.length) else site.social %}
<div class="h-card p-author" itemscope itemtype="http://schema.org/Person">
{# Hidden u-photo for reliable microformat parsing (some parsers struggle with img inside links) #}
<data class="u-photo hidden" value="{{ site.author.avatar }}"></data>
<data class="u-photo hidden" value="{{ authorAvatar }}"></data>
<div class="flex items-center gap-4">
<a href="{{ site.author.url }}" class="u-url u-uid" rel="me" itemprop="url">
<a href="{{ authorUrl }}" class="u-url u-uid" rel="me" itemprop="url">
<img
src="{{ site.author.avatar }}"
alt="{{ site.author.name }}"
src="{{ authorAvatar }}"
alt="{{ authorName }}"
class="w-16 h-16 rounded-full object-cover"
loading="lazy"
itemprop="image"
>
</a>
<div>
<a href="{{ site.author.url }}" class="u-url p-name font-bold text-lg block hover:text-primary-600 dark:hover:text-primary-400" itemprop="name">
{{ site.author.name }}
<a href="{{ authorUrl }}" class="u-url p-name font-bold text-lg block hover:text-primary-600 dark:hover:text-primary-400" itemprop="name">
{{ authorName }}
</a>
{% if site.author.pronoun %}
<span class="p-pronoun text-xs text-surface-500">({{ site.author.pronoun }})</span>
{% if authorPronoun %}
<span class="p-pronoun text-xs text-surface-500">({{ authorPronoun }})</span>
{% endif %}
<p class="p-job-title text-sm text-surface-600 dark:text-surface-400" itemprop="jobTitle">{{ site.author.title }}</p>
<p class="p-job-title text-sm text-surface-600 dark:text-surface-400" itemprop="jobTitle">{{ authorTitle }}</p>
{# Structured address #}
<p class="p-adr h-adr text-sm text-surface-500 dark:text-surface-500" itemprop="address" itemscope itemtype="http://schema.org/PostalAddress">
{% if site.author.locality %}
<span class="p-locality" itemprop="addressLocality">{{ site.author.locality }}</span>{% if site.author.country %}, {% endif %}
{% if authorLocality %}
<span class="p-locality" itemprop="addressLocality">{{ authorLocality }}</span>{% if authorCountry %}, {% endif %}
{% endif %}
{% if site.author.country %}
<span class="p-country-name" itemprop="addressCountry">{{ site.author.country }}</span>
{% if authorCountry %}
<span class="p-country-name" itemprop="addressCountry">{{ authorCountry }}</span>
{% endif %}
{# Fallback to legacy location field #}
{% if not site.author.locality and site.author.location %}
<span class="p-locality">{{ site.author.location }}</span>
{% if not authorLocality and authorLocation %}
<span class="p-locality">{{ authorLocation }}</span>
{% endif %}
</p>
</div>
</div>
{# Bio #}
<p class="p-note mt-3 text-sm text-surface-700 dark:text-surface-300" itemprop="description">{{ site.author.bio }}</p>
<p class="p-note mt-3 text-sm text-surface-700 dark:text-surface-300" itemprop="description">{{ authorBio }}</p>
{# Organization #}
{% if site.author.org %}
{% if authorOrg %}
<p class="mt-2 text-sm text-surface-600 dark:text-surface-400">
<span class="p-org" itemprop="worksFor">{{ site.author.org }}</span>
<span class="p-org" itemprop="worksFor">{{ authorOrg }}</span>
</p>
{% endif %}
{# Email and PGP Key #}
<div class="mt-2 flex flex-wrap gap-3 text-sm">
{% if site.author.email %}
{% if authorEmail %}
{# Display text obfuscated to deter spam harvesters; href kept plain for browser compatibility #}
<a href="mailto:{{ site.author.email }}" class="u-email text-primary-600 dark:text-primary-400 hover:underline" itemprop="email">
✉️ {{ site.author.email | obfuscateEmail | safe }}
<a href="mailto:{{ authorEmail }}" class="u-email text-primary-600 dark:text-primary-400 hover:underline" itemprop="email">
✉️ {{ authorEmail | obfuscateEmail | safe }}
</a>
{% endif %}
{% if site.author.keyUrl %}
<a href="{{ site.author.keyUrl }}" class="u-key text-surface-500 dark:text-surface-400 hover:underline" rel="pgpkey">
{% if authorKeyUrl %}
<a href="{{ authorKeyUrl }}" class="u-key text-surface-500 dark:text-surface-400 hover:underline" rel="pgpkey">
🔐 PGP Key
</a>
{% endif %}
</div>
{# Categories / Skills #}
{% if site.author.categories and site.author.categories.length %}
{% if authorCategories and authorCategories.length %}
<div class="mt-3 flex flex-wrap gap-1">
{% for category in site.author.categories %}
{% for category in authorCategories %}
<span class="p-category text-xs px-2 py-0.5 bg-surface-100 dark:bg-surface-800 rounded">{{ category }}</span>
{% endfor %}
</div>
{% endif %}
{# Social links with rel="me" - critical for IndieWeb identity verification #}
{% if site.social and site.social.length %}
{% if socialLinks and socialLinks.length %}
<nav class="flex flex-wrap gap-3 mt-3" aria-label="Social links">
{% for link in site.social %}
{% for link in socialLinks %}
<a
href="{{ link.url }}"
rel="{{ link.rel }} noopener"

View File

@@ -4,14 +4,21 @@
#}
{% set heroConfig = homepageConfig.hero or {} %}
{% set id = homepageConfig.identity if (homepageConfig and homepageConfig.identity) else {} %}
{% set authorName = id.name or site.author.name %}
{% set authorAvatar = id.avatar or site.author.avatar %}
{% set authorTitle = id.title or site.author.title %}
{% set authorBio = id.bio or site.author.bio %}
{% set siteDescription = id.description or site.description %}
{% set socialLinks = id.social if (id.social and id.social.length) else site.social %}
<section class="mb-8 sm:mb-12">
<div class="flex flex-col sm:flex-row gap-6 sm:gap-8 items-start">
{# Avatar #}
{% if heroConfig.showAvatar != false %}
<img
src="{{ site.author.avatar }}"
alt="{{ site.author.name }}"
src="{{ authorAvatar }}"
alt="{{ authorName }}"
class="w-24 h-24 sm:w-32 sm:h-32 rounded-full object-cover shadow-lg flex-shrink-0"
loading="eager"
>
@@ -20,27 +27,27 @@
{# Introduction #}
<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 }}
{{ authorName }}
</h1>
<p class="text-lg sm:text-xl text-primary-600 dark:text-primary-400 mb-3 sm:mb-4">
{{ site.author.title }}
{{ authorTitle }}
</p>
{% if site.author.bio %}
{% if authorBio %}
<p class="text-base sm:text-lg text-surface-700 dark:text-surface-300 mb-4">
{{ site.author.bio }}
{{ authorBio }}
</p>
{% endif %}
{% if site.description %}
{% if siteDescription %}
<p class="text-base sm:text-lg text-surface-700 dark:text-surface-300 mb-4 sm:mb-6">
{{ site.description }}
{{ siteDescription }}
<a href="/about/" class="text-primary-600 dark:text-primary-400 hover:underline font-medium">Read more &rarr;</a>
</p>
{% endif %}
{# Social Links #}
{% if heroConfig.showSocial != false and site.social %}
{% if heroConfig.showSocial != false and socialLinks %}
<div class="flex flex-wrap gap-3">
{% for link in site.social %}
{% for link in socialLinks %}
<a
href="{{ link.url }}"
rel="{{ link.rel }} noopener"

View File

@@ -0,0 +1,177 @@
# Editable Identity via Homepage Plugin — Design
## Goal
Make all author identity fields (hero content, h-card data, social links) editable from the Indiekit admin UI, stored permanently in MongoDB via the homepage plugin's existing `identity` field.
## Architecture
Extend `indiekit-endpoint-homepage` with a three-tab admin interface and an `identity` data section. The theme templates check `homepageConfig.identity.*` first, falling back to `site.author.*` environment variables. No new plugin, no new data file — identity lives in the existing `homepage.json`.
## Tab Structure
### Tab 1: Homepage Builder (`/homepage`)
Existing functionality, reorganized. Contains:
- Layout selection (presets + radio options)
- Hero config (enabled, show social toggles)
- Content Sections (drag-drop)
- Homepage Sidebar (drag-drop)
- Footer (drag-drop)
### Tab 2: Blog Sidebar (`/homepage/blog-sidebar`)
Extracted from the current single-page dashboard:
- Blog Listing Sidebar (drag-drop widget list)
- Blog Post Sidebar (drag-drop widget list)
### Tab 3: Identity (`/homepage/identity`)
New form page with sections:
**Profile:**
- `name` — text input
- `avatar` — text input (URL)
- `title` — text input (job title / subtitle)
- `pronoun` — text input
- `bio` — textarea
- `description` — textarea (site description shown in hero)
**Location:**
- `locality` — text input (city)
- `country` — text input
- `org` — text input (organization)
**Contact:**
- `url` — text input (author URL)
- `email` — text input
- `keyUrl` — text input (PGP key URL)
**Skills:**
- `categories` — tag-input component (comma-separated skills/interests)
**Social Links:**
- Full CRUD list using add-another pattern
- Each entry: `name` (text), `url` (text), `rel` (text, default "me"), `icon` (select from: github, linkedin, bluesky, mastodon, activitypub)
- Add, remove, reorder
## Data Model
### MongoDB Document (`homepageConfig` collection)
The existing singleton document gains an `identity` field:
```javascript
{
_id: "homepage",
layout: "two-column",
hero: { enabled: true, showSocial: true },
sections: [...],
sidebar: [...],
blogListingSidebar: [...],
blogPostSidebar: [...],
footer: [...],
identity: {
name: "Ricardo Mendes",
avatar: "https://...",
title: "Middleware Engineer & DevOps Specialist",
pronoun: "he/him",
bio: "Building infrastructure, automating workflows...",
description: "DevOps engineer, IndieWeb enthusiast...",
locality: "Brussels",
country: "Belgium",
org: "",
url: "https://rmendes.net",
email: "rick@example.com",
keyUrl: "https://...",
categories: ["IndieWeb", "OSINT", "DevOps"],
social: [
{ name: "GitHub", url: "https://github.com/rmdes", rel: "me", icon: "github" },
{ name: "Bluesky", url: "https://bsky.app/profile/rmendes.net", rel: "me atproto", icon: "bluesky" },
...
]
},
updatedAt: "ISO 8601 string"
}
```
### JSON File
Written to `content/.indiekit/homepage.json` on save (same as existing behavior). Eleventy file watcher triggers rebuild.
## Data Precedence
```
homepageConfig.identity.name → if truthy, use it
→ else fall back to site.author.name (env var)
```
Applied in the theme templates (hero.njk, h-card.njk) and anywhere else that reads `site.author.*` or `site.social`.
The simplest approach: create a computed `author` object in `_data/homepageConfig.js` that merges identity over site.author, so templates can use a single variable.
## Components Used (from @rmdes/indiekit-frontend)
| Field | Component |
|-------|-----------|
| Name, title, pronoun, locality, country, org, url, email, keyUrl | `input()` macro |
| Bio, description | `textarea()` macro |
| Categories/skills | `tag-input()` macro |
| Social links | `add-another()` macro wrapping input fields per entry |
| Icon selection | `select()` macro with predefined icon options |
| Form sections | `fieldset()` macro with legends |
| Save/cancel | `button()` macro (primary/secondary) |
| Errors | `errorSummary()` + field-level `errorMessage` |
## Tab Navigation
URL-based tabs using server-rendered pages (not client-side switching):
- `GET /homepage` — Homepage Builder tab
- `GET /homepage/blog-sidebar` — Blog Sidebar tab
- `GET /homepage/identity` — Identity tab
Each tab is a separate form with its own POST endpoint:
- `POST /homepage/save` — existing, handles layout/hero/sections/sidebar/footer
- `POST /homepage/save-blog-sidebar` — handles blogListingSidebar + blogPostSidebar
- `POST /homepage/save-identity` — handles identity fields
A shared tab navigation bar appears at the top of all three pages.
## Files Changed
### indiekit-endpoint-homepage (plugin)
| File | Change |
|------|--------|
| `index.js` | Add identity routes, identity configSchema |
| `lib/controllers/dashboard.js` | Split blog sidebar into separate GET handler, add identity GET/POST |
| `lib/controllers/api.js` | Add identity to public config endpoint |
| `lib/storage/config.js` | Handle identity save/merge |
| `views/homepage-dashboard.njk` | Remove blog sidebar sections, add tab nav |
| `views/homepage-blog-sidebar.njk` | New — extracted blog sidebar UI |
| `views/homepage-identity.njk` | New — identity editor form |
| `views/partials/tab-nav.njk` | New — shared tab navigation partial |
| `locales/en.json` | Add identity i18n strings |
### indiekit-eleventy-theme (theme)
| File | Change |
|------|--------|
| `_data/homepageConfig.js` | Merge identity over site.author, expose computed `author` object |
| `_includes/components/sections/hero.njk` | Use merged author data instead of raw site.author |
| `_includes/components/h-card.njk` | Use merged author data instead of raw site.author |
## Not Changed
- `_data/site.js` — env vars remain as fallback source, untouched
- Main feed templates — don't reference author data
- Post layout — uses site.author for meta tags (will get override via merged data)
- Other plugins — no changes needed
## Constraints
- Identity editor uses standard Indiekit frontend components (no custom JS beyond add-another)
- Social link icons limited to the set already defined in hero.njk/h-card.njk SVGs (github, linkedin, bluesky, mastodon, activitypub) — extensible later
- Avatar is a URL field, not file upload (avatar image hosting is separate)
- All dates stored as ISO 8601 strings

View File

@@ -0,0 +1,468 @@
# Editable Identity via Homepage Plugin — Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Make all author identity fields editable from the Indiekit admin UI via a three-tab homepage dashboard with Identity CRUD.
**Architecture:** Extend `indiekit-endpoint-homepage` with URL-based tabs (Homepage Builder, Blog Sidebar, Identity). Identity data stored in `homepageConfig.identity` in MongoDB + `homepage.json`. Theme templates check identity data first, falling back to `site.author.*` env vars.
**Tech Stack:** Express.js, Nunjucks, @rmdes/indiekit-frontend components, MongoDB, Eleventy
---
### Task 1: Add tab navigation partial and new routes in index.js
**Files:**
- Create: `indiekit-endpoint-homepage/views/partials/tab-nav.njk`
- Modify: `indiekit-endpoint-homepage/index.js`
**Step 1: Create the tab navigation partial**
Create `views/partials/tab-nav.njk`:
```nunjucks
{# Tab navigation for homepage admin - server-rendered URL tabs #}
<style>
.hp-tab-nav {
display: flex;
gap: 0;
border-bottom: 2px solid var(--color-outline-variant, #ddd);
margin-block-end: var(--space-xl, 2rem);
}
.hp-tab-nav__item {
padding: var(--space-s, 0.75rem) var(--space-m, 1.25rem);
text-decoration: none;
color: var(--color-on-offset, #666);
font-weight: 500;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
transition: color 0.2s, border-color 0.2s;
}
.hp-tab-nav__item:hover {
color: var(--color-primary, #0066cc);
}
.hp-tab-nav__item--active {
color: var(--color-primary, #0066cc);
border-bottom-color: var(--color-primary, #0066cc);
font-weight: 600;
}
</style>
<nav class="hp-tab-nav" aria-label="Homepage settings">
<a href="{{ homepageEndpoint }}"
class="hp-tab-nav__item{% if activeTab == 'builder' %} hp-tab-nav__item--active{% endif %}">
{{ __("homepageBuilder.tabs.builder") }}
</a>
<a href="{{ homepageEndpoint }}/blog-sidebar"
class="hp-tab-nav__item{% if activeTab == 'blog-sidebar' %} hp-tab-nav__item--active{% endif %}">
{{ __("homepageBuilder.tabs.blogSidebar") }}
</a>
<a href="{{ homepageEndpoint }}/identity"
class="hp-tab-nav__item{% if activeTab == 'identity' %} hp-tab-nav__item--active{% endif %}">
{{ __("homepageBuilder.tabs.identity") }}
</a>
</nav>
```
**Step 2: Add new routes in index.js**
In the `get routes()` getter, after the existing routes (after `protectedRouter.get("/api/config", apiController.getConfig);`), add:
```javascript
// Blog sidebar tab
protectedRouter.get("/blog-sidebar", dashboardController.getBlogSidebar);
protectedRouter.post("/save-blog-sidebar", dashboardController.saveBlogSidebar);
// Identity tab
protectedRouter.get("/identity", dashboardController.getIdentity);
protectedRouter.post("/save-identity", dashboardController.saveIdentity);
```
**Step 3: Verify**
Run: `node -e "import('./index.js')"` from the plugin directory to check for syntax errors.
**Step 4: Commit**
```bash
git add views/partials/tab-nav.njk index.js
git commit -m "feat(homepage): add tab navigation partial and identity/blog-sidebar routes"
```
---
### Task 2: Add i18n strings for tabs and identity editor
**Files:**
- Modify: `indiekit-endpoint-homepage/locales/en.json`
**Step 1: Add the new i18n keys**
Add `"tabs"` block as a new top-level key inside `"homepageBuilder"`, and add `"identity"` block. Keep all existing keys unchanged.
Add under `"homepageBuilder"`:
```json
"tabs": {
"builder": "Homepage",
"blogSidebar": "Blog Sidebar",
"identity": "Identity"
},
```
Add the `"identity"` block:
```json
"identity": {
"title": "Identity",
"description": "Configure your author profile, contact details, and social links. These override environment variable defaults.",
"saved": "Identity saved successfully. Refresh your site to see changes.",
"profile": {
"legend": "Profile",
"name": { "label": "Name", "hint": "Your display name" },
"avatar": { "label": "Avatar URL", "hint": "URL to your avatar image" },
"title": { "label": "Title", "hint": "Job title or subtitle" },
"pronoun": { "label": "Pronoun", "hint": "e.g. he/him, she/her, they/them" },
"bio": { "label": "Bio", "hint": "Short biography" },
"description": { "label": "Site Description", "hint": "Description shown in the hero section" }
},
"location": {
"legend": "Location",
"locality": { "label": "City", "hint": "City or locality" },
"country": { "label": "Country" },
"org": { "label": "Organization", "hint": "Company or organization" }
},
"contact": {
"legend": "Contact",
"url": { "label": "URL", "hint": "Your personal website URL" },
"email": { "label": "Email" },
"keyUrl": { "label": "PGP Key URL", "hint": "URL to your public PGP key" }
},
"skills": {
"legend": "Skills & Interests",
"categories": { "label": "Categories", "hint": "Comma-separated skills, interests, or tags" }
},
"social": {
"legend": "Social Links",
"description": "Add links to your social profiles. These appear in the hero section and h-card.",
"name": { "label": "Name" },
"url": { "label": "URL" },
"rel": { "label": "Rel" },
"icon": { "label": "Icon" }
}
}
```
**Step 2: Verify no JSON syntax errors**
Run: `node -e "JSON.parse(require('fs').readFileSync('locales/en.json','utf8')); console.log('OK')"`
**Step 3: Commit**
```bash
git add locales/en.json
git commit -m "feat(homepage): add i18n strings for tabs and identity editor"
```
---
### Task 3: Add dashboard controller methods for blog sidebar and identity
**Files:**
- Modify: `indiekit-endpoint-homepage/lib/controllers/dashboard.js`
**Step 1: Add `parseSocialLinks` helper function**
Add at the top of the file, after the existing imports:
```javascript
/**
* Parse social links from form body.
* Express parses social[0][name], social[0][url] etc. into nested objects.
*/
function parseSocialLinks(body) {
const social = [];
if (!body.social) return social;
const entries = Array.isArray(body.social) ? body.social : Object.values(body.social);
for (const entry of entries) {
if (!entry || (!entry.name && !entry.url)) continue;
social.push({
name: entry.name || "",
url: entry.url || "",
rel: entry.rel || "me",
icon: entry.icon || "",
});
}
return social;
}
```
**Step 2: Update the existing `get` method**
Add `activeTab: "builder"` to the `response.render()` call.
**Step 3: Update the existing `save` method**
The save method must preserve blog sidebar and identity data that are no longer part of the homepage builder form. Read the current config first and merge:
```javascript
// Get current config to preserve fields not in this form
let currentConfig = await getConfig(application);
const config = {
layout: layout || "single-column",
hero: typeof hero === "string" ? JSON.parse(hero) : hero,
sections: typeof sections === "string" ? JSON.parse(sections) : sections,
sidebar: typeof sidebar === "string" ? JSON.parse(sidebar) : sidebar,
blogListingSidebar: currentConfig?.blogListingSidebar || [],
blogPostSidebar: currentConfig?.blogPostSidebar || [],
footer: typeof footer === "string" ? JSON.parse(footer) : footer,
identity: currentConfig?.identity || null,
};
```
**Step 4: Add `getBlogSidebar` controller method**
Renders `homepage-blog-sidebar` view with `activeTab: "blog-sidebar"`, current config, widgets, and blogPostWidgets.
**Step 5: Add `saveBlogSidebar` controller method**
Reads `blogListingSidebar` and `blogPostSidebar` from request body, preserves all other config fields, saves, redirects to `/homepage/blog-sidebar?saved=1`.
**Step 6: Add `getIdentity` controller method**
Reads `config.identity || {}`, renders `homepage-identity` view with `activeTab: "identity"`.
**Step 7: Add `saveIdentity` controller method**
Parses form fields (`identity-name`, `identity-bio`, etc.) and social links using `parseSocialLinks(body)`. Builds identity object, preserves all other config fields, saves, redirects to `/homepage/identity?saved=1`.
**Step 8: Verify**
Run: `node -e "import('./lib/controllers/dashboard.js')"` to check for syntax errors.
**Step 9: Commit**
```bash
git add lib/controllers/dashboard.js
git commit -m "feat(homepage): add blog sidebar and identity controller methods"
```
---
### Task 4: Refactor homepage-dashboard.njk — remove blog sidebar, add tab nav
**Files:**
- Modify: `indiekit-endpoint-homepage/views/homepage-dashboard.njk`
**Step 1: Add tab nav include**
After the page header `</header>`, before the success message `{% if request.query.saved %}`, add:
```nunjucks
{% include "partials/tab-nav.njk" %}
```
**Step 2: Remove blog sidebar sections from HTML**
Remove the two `<section>` blocks:
- Blog Listing Sidebar (with `id="blog-listing-sidebar-list"` and `id="blog-listing-sidebar-json"`)
- Blog Post Sidebar (with `id="blog-post-sidebar-list"` and `id="blog-post-sidebar-json"`)
**Step 3: Remove blog sidebar JavaScript**
From the `<script>` block, remove:
- `var blogListingSidebar = ...` and `var blogPostSidebar = ...` parsing
- `.forEach` key assignment for both
- All `addBlogListingWidget`, `removeBlogListingWidget`, `editBlogListingWidget`, `updateBlogListingSidebar` functions
- All `addBlogPostWidget`, `removeBlogPostWidget`, `editBlogPostWidget`, `updateBlogPostSidebar` functions
- `syncBlogListingSidebarFromDom` and `syncBlogPostSidebarFromDom`
- SortableJS entries for `blog-listing-sidebar-list` and `blog-post-sidebar-list`
- Initial render calls `updateBlogListingSidebar()` and `updateBlogPostSidebar()`
- Event listeners for `[data-add-blog-listing-widget]` and `[data-add-blog-post-widget]`
**Step 4: Verify**
Navigate to `/homepage` in the admin UI. Tab nav appears. Blog sidebar sections are gone. Saving layout/hero/sections/sidebar/footer still works.
**Step 5: Commit**
```bash
git add views/homepage-dashboard.njk
git commit -m "refactor(homepage): extract blog sidebar from main dashboard, add tab nav"
```
---
### Task 5: Create blog sidebar tab view
**Files:**
- Create: `indiekit-endpoint-homepage/views/homepage-blog-sidebar.njk`
**Step 1: Create the view**
This view extends `document.njk`, includes the tab nav, and contains the Blog Listing Sidebar and Blog Post Sidebar sections extracted from the old homepage-dashboard.njk. It includes:
- Same CSS classes as the main dashboard (`hp-section`, `hp-sections-list`, `hp-section-item`, etc.)
- Tab nav include with `activeTab: "blog-sidebar"`
- Two sections: Blog Listing Sidebar and Blog Post Sidebar (same HTML structure as before)
- Hidden JSON inputs for `blogListingSidebar` and `blogPostSidebar`
- Form POSTs to `{{ homepageEndpoint }}/save-blog-sidebar`
- Inline JS for the blog sidebar functions (stripKeys, createDragHandle, createItemElement, renderList, add/remove/edit/update/sync functions, SortableJS init)
The shared JS utility functions (`stripKeys`, `createDragHandle`, `createItemElement`, `renderList`) are duplicated into this view, matching the existing inline JS pattern.
**Step 2: Verify**
Navigate to `/homepage/blog-sidebar`. Tab nav shows "Blog Sidebar" as active. Widgets render. Drag-drop works. Save persists data.
**Step 3: Commit**
```bash
git add views/homepage-blog-sidebar.njk
git commit -m "feat(homepage): add blog sidebar tab view"
```
---
### Task 6: Create identity editor tab view
**Files:**
- Create: `indiekit-endpoint-homepage/views/homepage-identity.njk`
**Step 1: Create the view**
This view extends `document.njk`, includes the tab nav, and contains the identity editor form. Uses standard Indiekit frontend macros (available globally from `default.njk`):
- `input()` for name, avatar URL, title, pronoun, locality, country, org, url, email, keyUrl
- `textarea()` for bio, description
- `tagInput()` for categories/skills
- Social links: inline JS with `createElement`/`textContent` for add/remove rows (safe DOM manipulation pattern matching existing dashboard)
- Each social link row has: name (text), url (text), rel (text, default "me"), icon (select: github/linkedin/bluesky/mastodon/activitypub)
- Form POSTs to `{{ homepageEndpoint }}/save-identity`
Social link form fields use the pattern `social[N][name]`, `social[N][url]`, `social[N][rel]`, `social[N][icon]` which Express parses into nested objects automatically.
Form is organized into sections:
- Profile (name, avatar, title, pronoun, bio, description)
- Location (locality, country, org)
- Contact (url, email, keyUrl)
- Skills (categories via tag-input)
- Social Links (CRUD list)
**Step 2: Verify**
Navigate to `/homepage/identity`. All fields render. Fill in data, submit. Data saves to MongoDB. Reload — data persists.
**Step 3: Commit**
```bash
git add views/homepage-identity.njk
git commit -m "feat(homepage): add identity editor tab with social links CRUD"
```
---
### Task 7: Verify API controller includes identity field
**Files:**
- Verify: `indiekit-endpoint-homepage/lib/controllers/api.js`
**Step 1: Check the `getConfigPublic` method**
Verify that `identity: config.identity` is included in the public API response. The current code at line 96 already includes this. If not, add it.
**Step 2: Verify**
Hit `GET /homepage/api/config.json` and confirm the response includes the `identity` field with saved data.
**Step 3: Commit (only if changes were needed)**
```bash
git add lib/controllers/api.js
git commit -m "chore(homepage): ensure identity field in public API response"
```
---
### Task 8: Update theme templates to prefer identity data over env vars
**Files:**
- Modify: `indiekit-eleventy-theme/_includes/components/sections/hero.njk`
- Modify: `indiekit-eleventy-theme/_includes/components/h-card.njk`
**Step 1: Update hero.njk**
After `{% set heroConfig = homepageConfig.hero or {} %}`, add identity resolution variables:
```nunjucks
{% set id = homepageConfig.identity if (homepageConfig and homepageConfig.identity) else {} %}
{% set authorName = id.name or site.author.name %}
{% set authorAvatar = id.avatar or site.author.avatar %}
{% set authorTitle = id.title or site.author.title %}
{% set authorBio = id.bio or site.author.bio %}
{% set siteDescription = id.description or site.description %}
{% set socialLinks = id.social if (id.social and id.social.length) else site.social %}
```
Replace all references:
- `site.author.name``authorName`
- `site.author.avatar``authorAvatar`
- `site.author.title``authorTitle`
- `site.author.bio``authorBio`
- `site.description``siteDescription`
- `site.social``socialLinks`
**Step 2: Update h-card.njk**
Add identity resolution at the top:
```nunjucks
{% set id = homepageConfig.identity if (homepageConfig and homepageConfig.identity) else {} %}
{% set authorName = id.name or site.author.name %}
{% set authorAvatar = id.avatar or site.author.avatar %}
{% set authorTitle = id.title or site.author.title %}
{% set authorBio = id.bio or site.author.bio %}
{% set authorUrl = id.url or site.author.url %}
{% set authorPronoun = id.pronoun or site.author.pronoun %}
{% set authorLocality = id.locality or site.author.locality %}
{% set authorCountry = id.country or site.author.country %}
{% set authorLocation = site.author.location %}
{% set authorOrg = id.org or site.author.org %}
{% set authorEmail = id.email or site.author.email %}
{% set authorKeyUrl = id.keyUrl or site.author.keyUrl %}
{% set authorCategories = id.categories if (id.categories and id.categories.length) else site.author.categories %}
{% set socialLinks = id.social if (id.social and id.social.length) else site.social %}
```
Replace all `site.author.*` and `site.social` references with the corresponding variables.
**Step 3: Verify**
Run Eleventy build locally (dryrun) to confirm no template errors:
```bash
cd /home/rick/code/indiekit-dev/indiekit-eleventy-theme
npx @11ty/eleventy --dryrun 2>&1 | tail -5
```
**Step 4: Commit**
```bash
git add _includes/components/sections/hero.njk _includes/components/h-card.njk
git commit -m "feat(theme): prefer identity data over env vars in hero and h-card"
```
---
### Task Summary
| # | Task | Repo | Depends On |
|---|------|------|-----------|
| 1 | Tab nav partial + routes | plugin | — |
| 2 | i18n strings | plugin | — |
| 3 | Dashboard controller methods | plugin | 1, 2 |
| 4 | Refactor homepage-dashboard.njk | plugin | 1, 3 |
| 5 | Create blog sidebar view | plugin | 1, 2, 3 |
| 6 | Create identity editor view | plugin | 1, 2, 3 |
| 7 | Verify API includes identity | plugin | 3 |
| 8 | Theme: identity over env vars | theme | all plugin tasks |