Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
.hlx/*
coverage/*
drafts/*
logs/*
node_modules/*

Expand Down
101 changes: 101 additions & 0 deletions blocks/post-index/post-index.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/* tva */

/* EDS section wrapper handles horizontal padding */
.post-index {
max-width: var(--measure);
margin-inline: auto;
padding-inline: 0;
}

.post-index .post-entry {
margin-block-end: var(--section-spacing);
}

.post-index .post-entry + .post-entry {
border-top: 1px solid var(--color-border-subtle);
padding-block-start: var(--section-spacing);
}

.post-index .post-type {
display: block;
font-family: var(--font-heading);
font-size: var(--body-font-size-xs);
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
line-height: 1;
}

.post-index h2 {
font-size: var(--heading-font-size-m);
font-weight: 600;
color: var(--color-heading);
line-height: var(--line-height-heading);
margin-block: 0;
}

/* Title link styles — intentionally ordered before tag link rules.
The tag link selectors (.post-tags a:hover) reach higher specificity via
pseudo-classes, so title link base styles appear first in source order. */
.post-index h2 a {
color: inherit;
text-decoration: none;
}

.post-index h2 a:hover {
text-decoration: underline;
}

.post-index h2 a:focus-visible {
outline: 2px solid var(--color-heading);
outline-offset: 2px;
}

.post-index .post-description {
font-size: var(--body-font-size-s);
color: var(--color-text);
line-height: var(--line-height-body);
}

.post-index .post-meta {
font-size: var(--body-font-size-xs);
color: var(--color-text-muted);
}

.post-index .post-tags {
display: inline;
list-style: none;
margin: 0;
padding: 0;
}

.post-index .post-tags li {
display: inline;
}

.post-index .post-tags li + li::before {
content: " \B7 ";
color: var(--color-text-muted);
}

.post-index .post-meta time + .post-tags::before {
content: " \B7 ";
color: var(--color-text-muted);
}

/* Tag link styles have higher specificity than h2 a due to .post-tags class.
Intentional: tag links are scoped to metadata, title links to headings. */
/* stylelint-disable-next-line no-descending-specificity */
.post-index .post-tags a {
color: var(--color-link);
text-decoration: none;
}

.post-index .post-tags a:hover {
text-decoration: underline;
}

.post-index .post-tags a:focus-visible {
outline: 2px solid var(--color-heading);
outline-offset: 2px;
}
206 changes: 206 additions & 0 deletions blocks/post-index/post-index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
// tva

/**
* Post Index block — fetches /query-index.json and renders a reverse-chronological
* list of post entries on the home page.
*
* Design spec: docs/design-decisions/DDD-004-home-post-index.md
*/

const TYPE_LABELS = {
'build-log': 'Build Log',
pattern: 'Pattern',
'tool-report': 'Tool Report',
til: 'TIL',
};

const DATE_FORMATTER = new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
});

/**
* Parse a date value from query-index.json.
* EDS may return Unix timestamps (seconds) or ISO strings.
* Returns a millisecond timestamp for use with Date().
* @param {string|number} value
* @returns {number}
*/
function parseDate(value) {
if (!value) return 0;
const num = Number(value);
if (!Number.isNaN(num) && num > 0 && num < 1e10) {
// EDS returns Unix timestamps in seconds. 1e10 distinguishes seconds (10 digits)
// from milliseconds (13 digits) to prevent double-multiplication.
return num * 1000;
}
return new Date(value).getTime() || 0;
}

/**
* Format a millisecond timestamp as YYYY-MM-DD for datetime attributes.
* @param {number} ms
* @returns {string}
*/
function toIsoDate(ms) {
const d = new Date(ms);
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}

/**
* Build a single post entry <article> element.
* @param {Object} entry - A single entry from query-index.json .data array
* @param {number} position - 1-based position in sorted list (for aria-labelledby IDs)
* @returns {HTMLElement|null} article element, or null if entry should be skipped
*/
function buildEntry(entry, position) {
const {
path, title, type, description, date, tags,
} = entry;

// Required field — skip entry entirely if missing
if (!title) return null;

// Security: validate path before use in href
if (!path || !path.startsWith('/')) return null;

const titleId = `post-${position}-title`;
const typeLabel = TYPE_LABELS[type] || null;

const article = document.createElement('article');
article.className = 'post-entry';
article.setAttribute('aria-labelledby', titleId);

// Type badge (visible, aria-hidden — heading sr-only prefix carries the type for AT)
if (typeLabel) {
const badge = document.createElement('span');
badge.className = 'post-type';
badge.setAttribute('aria-hidden', 'true');
badge.textContent = typeLabel;
article.append(badge);
}

// Title heading
const h2 = document.createElement('h2');
h2.id = titleId;

if (typeLabel) {
// sr-only prefix gives screen reader heading nav the type context.
// Trailing ': ' (colon-space) ensures AT separates type from title without
// relying on whitespace text nodes.
const srPrefix = document.createElement('span');
srPrefix.className = 'sr-only';
srPrefix.textContent = `${typeLabel}: `;
h2.append(srPrefix);
}

const titleLink = document.createElement('a');
titleLink.href = path;
titleLink.textContent = title;
h2.append(titleLink);
article.append(h2);

// Description
if (description) {
const p = document.createElement('p');
p.className = 'post-description';
p.textContent = description;
article.append(p);
}

// Metadata footer (date + tags)
const footer = document.createElement('footer');
footer.className = 'post-meta';

const dateMs = parseDate(date);
if (dateMs) {
const time = document.createElement('time');
time.setAttribute('datetime', toIsoDate(dateMs));
time.textContent = DATE_FORMATTER.format(new Date(dateMs));
footer.append(time);
}

if (tags) {
const slugs = tags
.split(',')
.map((t) => t.trim())
.filter((t) => /^[a-z0-9-]+$/.test(t)); // validate slug before use in href

if (slugs.length > 0) {
// Intentionally no aria-label on tag list -- single list per article makes it unambiguous
const ul = document.createElement('ul');
ul.className = 'post-tags';

slugs.forEach((slug) => {
const li = document.createElement('li');
const a = document.createElement('a');
a.href = `/tags/${slug}`;
a.textContent = slug;
li.append(a);
ul.append(li);
});

footer.append(ul);
}
}

// Only append footer if it has content
if (footer.children.length > 0) {
article.append(footer);
}

return article;
}

/**
* Loads and decorates the post-index block.
* Fetches /query-index.json, sorts by date descending, renders entries.
* @param {Element} block The block element
*/
export default async function decorate(block) {
// sr-only h1 must appear first in DOM order
const h1 = document.createElement('h1');
h1.className = 'sr-only';
h1.textContent = 'Posts';

block.textContent = '';
block.append(h1);

let entries;
try {
const response = await fetch('/query-index.json');
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const json = await response.json();
entries = json.data;
} catch (error) {
// eslint-disable-next-line no-console
console.error('Post index: failed to load query-index.json', error);
return;
}

if (!Array.isArray(entries) || entries.length === 0) {
// eslint-disable-next-line no-console
console.warn('Post index: query-index.json returned no entries');
return;
}

// Sort by date descending; entries without dates fall to end
entries.sort((a, b) => {
const da = parseDate(a.date);
const db = parseDate(b.date);
return db - da;
});

entries.forEach((entry, i) => {
const article = buildEntry(entry, i + 1);
if (article) {
block.append(article);
}
});
}
23 changes: 23 additions & 0 deletions docs/content-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,26 @@ Tags only. Flat namespace. Lowercase, hyphenated.
## Structured Data

JSON-LD on every post page using `TechArticle` with `proficiencyLevel: Expert`.

## Query Index

Blog post metadata is exposed to the frontend via the EDS content index. The `helix-query.yaml` file at the project root controls indexing.

**Endpoint:** `/query-index.json`

**Include path:** `/blog/**` (only blog posts are indexed)

**Indexed columns:**

| Column | Source | Notes |
|---|---|---|
| `path` | Automatic | Page URL path. Included by EDS automatically. |
| `title` | `og:title` meta tag | Page title. |
| `description` | `description` meta tag | 1-2 sentence summary. |
| `date` | `date` meta tag | Publication date. Used for sort order. |
| `type` | `type` meta tag | Post type enum. |
| `tags` | `tags` meta tag | Comma-separated tag slugs. |

Adding a new metadata field to posts requires a corresponding property entry in `helix-query.yaml` to make it available in the index.

Changes to `helix-query.yaml` take effect after EDS reindexes content (automatic on next content preview/publish).
13 changes: 11 additions & 2 deletions docs/design-decisions/DDD-004-home-post-index.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# DDD-004: Home Page Post Index

Status: **Proposal**
Status: **Implemented**

## Context

Expand Down Expand Up @@ -546,4 +546,13 @@ The `description` field shown in each index entry can come from two sources: (a)

### Reviewer Notes

_Human writes here during review._
**Implemented** on 2026-03-13, branch `nefario/ddd-004-home-post-index`.

**Open Question resolutions:**
- OQ1 (entry spacing): Using `--section-spacing` (48px) as the initial value. Can be tuned with real content.
- OQ2 (tag casing): Tags displayed as lowercase slugs, matching the content model canonical form.
- OQ3 (tag links before DDD-007): Tags rendered as links from day one. 404s are acceptable -- the 404 page has a "Go home" link.
- OQ4 (helix-query.yaml): Configured with standard EDS property selectors. `path` automatic.
- OQ5 (.sr-only placement): Defined in `styles/styles.css` (eager) rather than `lazy-styles.css`, per accessibility review.
- OQ6 (auto-block vs authored): Auto-block via `buildPostIndexBlock()` in `scripts.js`, detecting `pathname === '/'`.
- OQ7 (description provenance): Uses `description` meta tag as authored by the writer. Not auto-extracted.
Loading