blog umami fix

This commit is contained in:
2026-02-10 01:34:07 -05:00
parent f056e67eae
commit c1ab51a149
15 changed files with 330 additions and 18 deletions

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-02-10

View File

@@ -0,0 +1,44 @@
## Context
The site uses Umami for analytics. Most site clickables are instrumented using Umamis data-attribute method (`data-umami-event` and optional `data-umami-event-*` properties) so events are recorded automatically on click.
The Blog section was added recently and its clickables (post cards, category nav, page links) are not consistently emitting Umami events. This creates a measurement blind spot for the `/blog` surface.
## Goals / Non-Goals
**Goals:**
- Ensure all blog clickables emit Umami events using the documented data-attribute method.
- Ensure every tracked clickable has a deterministic, unique `target_id` and includes at minimum `placement` and `target_url` per taxonomy.
- Keep event names within Umami limits (<= 50 chars) and avoid sending event data without an event name.
- Add tests to prevent regressions (blog pages/components should contain required Umami attributes).
**Non-Goals:**
- Introducing custom JavaScript tracking (`window.umami.track`) for v1; we will use Umamis data-attribute method.
- Adding new analytics providers or changing Umami initialization.
- Tracking PII or user-generated content in event properties.
## Decisions
- **Decision: Use Umami-native data attributes on every blog clickable.**
- Rationale: Aligns with Umamis “Track events” docs and the rest of the sites tracking approach; avoids adding JS listeners that can interfere with other handlers.
- **Decision: Use consistent event names by clickable type.**
- Rationale: Keeps reporting clean while still allowing segmentation via event properties.
- Proposed:
- `click` for internal navigation links (including blog category navigation)
- `outbound_click` for external links (if any in blog chrome)
- **Decision: Add a deterministic `target_id` namespace for blog elements.**
- Rationale: Blog has many repeated elements; we need unique IDs that remain stable across builds.
- Proposed conventions:
- Blog header link: `nav.blog`
- Blog secondary nav: `blog.subnav.all`, `blog.subnav.pages`, `blog.subnav.category.<slug>`
- Blog post card: `blog.card.post.<slug>` (placement `blog.index` or `blog.category.<slug>`)
- Blog post detail back link: `blog.post.back`
- Blog page list links: `blog.pages.link.<slug>`
## Risks / Trade-offs
- [Risk] Some blog content areas render raw HTML from WordPress; links inside content are not instrumented. -> Mitigation: Track the blog chrome (cards/nav/back links) first; consider JS-based delegated tracking for content-body links in a future change if needed.
- [Risk] Over-instrumentation adds noisy events. -> Mitigation: Keep event names simple, rely on `target_id` + `placement` for segmentation, and avoid tracking non-clickable elements.

View File

@@ -0,0 +1,26 @@
## Why
The Blog sections click tracking is not firing reliably in Umami, which prevents measuring what users do in `/blog` and where they go next.
## What Changes
- Update the Blog section UI so every clickable element uses Umamis data-attribute event tracking format:
- `data-umami-event="<event-name>"`
- `data-umami-event-*` attributes for event data
- Ensure every tracked clickable item has a unique, deterministic set of event data elements (especially `target_id`, `placement`, `target_url`) so clicks can be measured independently.
- Add verification/tests to ensure Blog clickables are instrumented and follow the same taxonomy as the rest of the site.
## Capabilities
### New Capabilities
- (none)
### Modified Capabilities
- `blog-section-surface`: instrument blog clickables (post cards, post/page links, category secondary nav, blog header link) using Umami `data-umami-event` attributes.
- `interaction-tracking-taxonomy`: extend/clarify tracking rules to cover blog-specific UI elements and namespaces for `target_id`.
- `analytics-umami`: ensure the implementation adheres to Umamis Track Events specification for data attributes.
## Impact
- Affected UI/components: blog pages and components under `site/src/pages/blog/` and `site/src/components/` (cards and secondary nav), plus any shared navigation link to `/blog`.
- Testing: add/update tests to assert required Umami data attributes exist and are unique per clickable element in blog surfaces.

View File

@@ -0,0 +1,28 @@
## MODIFIED Requirements
### Requirement: Custom event tracking
When Umami is enabled, the site MUST support custom event emission for:
- `cta_click`
- `outbound_click`
- a general click interaction event for all instrumented clickable items (per the site tracking taxonomy)
Each emitted event MUST include enough properties to segment reports by platform and placement when applicable.
All tracked clickable items MUST emit events with a unique, consistent set of data elements as defined by the site tracking taxonomy, including at minimum `target_id` and `placement`.
The site MUST instrument tracked clickables using Umamis supported Track Events data-attribute method:
- `data-umami-event="<event-name>"`
- optional event data using `data-umami-event-*`
#### Scenario: Emit outbound click event
- **WHEN** a user clicks a non-CTA outbound link from the homepage
- **THEN** the system emits an `outbound_click` event with a property identifying the destination domain
#### Scenario: Emit general click event for any clickable
- **WHEN** a user clicks an instrumented navigation link
- **THEN** the system emits a click interaction event with `target_id` and `placement`
#### Scenario: Uninstrumented clicks do not break the page
- **WHEN** a user clicks an element with no tracking metadata
- **THEN** the system does not throw and navigation/interaction proceeds normally

View File

@@ -0,0 +1,47 @@
## MODIFIED Requirements
### Requirement: Blog index listing (posts)
The site MUST provide a blog index page at `/blog` that lists WordPress posts as cards containing:
- featured image (when available)
- title
- excerpt/summary
The listing MUST be ordered by publish date descending (newest first).
Each post card MUST be instrumented with Umami Track Events data attributes and MUST include at minimum:
- `data-umami-event`
- `data-umami-event-target_id`
- `data-umami-event-placement`
- `data-umami-event-target_url`
#### Scenario: Blog index lists posts
- **WHEN** the cached WordPress dataset contains posts
- **THEN** `/blog` renders a list of post cards ordered by publish date descending
#### Scenario: Blog post card click is tracked
- **WHEN** a user clicks a blog post card on `/blog`
- **THEN** the click emits an Umami event with `target_id`, `placement`, and `target_url`
### Requirement: Category-based secondary navigation
The blog section MUST render a secondary navigation under the header derived from the cached WordPress categories.
Selecting a category MUST navigate to a category listing page showing only posts in that category.
Each secondary navigation link MUST be instrumented with Umami Track Events data attributes and MUST include at minimum:
- `data-umami-event`
- `data-umami-event-target_id`
- `data-umami-event-placement`
- `data-umami-event-target_url`
#### Scenario: Category nav present
- **WHEN** the cached WordPress dataset contains categories
- **THEN** the blog section shows a secondary navigation with those categories
#### Scenario: Category listing filters posts
- **WHEN** a user navigates to a category listing page
- **THEN** only posts assigned to that category are listed
#### Scenario: Category nav click is tracked
- **WHEN** a user clicks a category link in the blog secondary navigation
- **THEN** the click emits an Umami event with `target_id`, `placement`, and `target_url`

View File

@@ -0,0 +1,17 @@
## MODIFIED Requirements
### Requirement: Unique identifier for every clickable item
Every clickable item that is tracked MUST have a stable identifier (`target_id`) that is unique across the site (or unique within a documented namespace).
The identifier MUST be deterministic across builds for the same element and placement.
The taxonomy MUST define namespaces for repeated UI surfaces. For the blog surface, the following namespaces MUST be used:
- `blog.subnav.*` for secondary navigation links
- `blog.card.post.<slug>` for blog post cards
- `blog.pages.link.<slug>` for blog page listing links
- `blog.post.*` / `blog.page.*` for detail page chrome links (e.g., back links)
#### Scenario: Two links in different placements
- **WHEN** two links point to the same destination but appear in different placements
- **THEN** their `target_id` values are different so their clicks can be measured independently

View File

@@ -0,0 +1,15 @@
## 1. Audit Blog Clickables
- [x] 1.1 Inventory blog clickables (`site/src/pages/blog/**`, `site/src/components/Blog*`) that should emit Umami events (post cards, category subnav, pages list links, detail chrome links)
- [x] 1.2 Confirm each clickable has the required Umami attributes and a deterministic unique `target_id` per taxonomy
## 2. Implement Umami Attributes
- [x] 2.1 Instrument blog secondary navigation links with `data-umami-event` and required event data (`target_id`, `placement`, `target_url`)
- [x] 2.2 Instrument blog post cards and any inline links in listing UIs with `data-umami-event` and required event data
- [x] 2.3 Instrument blog detail page chrome links (e.g., Back) and pages listing links with required Umami attributes
## 3. Verify
- [x] 3.1 Add/update tests to assert blog components/pages contain Umami `data-umami-event` attributes (and key properties like `target_id`, `placement`, `target_url`)
- [x] 3.2 Build the site and confirm `/blog` and a blog detail page render with instrumented clickables

View File

@@ -3,9 +3,11 @@ import type { WordpressPost } from "../lib/content/types";
type Props = { type Props = {
post: WordpressPost; post: WordpressPost;
placement: string;
targetId: string;
}; };
const { post } = Astro.props; const { post, placement, targetId } = Astro.props;
function truncate(s: string, n: number) { function truncate(s: string, n: number) {
if (!s) return ""; if (!s) return "";
@@ -15,11 +17,17 @@ function truncate(s: string, n: number) {
} }
--- ---
<a class="blog-card" href={`/blog/post/${post.slug}`}> <a
class="blog-card"
href={`/blog/post/${post.slug}`}
data-umami-event="click"
data-umami-event-target_id={targetId}
data-umami-event-placement={placement}
data-umami-event-target_url={`/blog/post/${post.slug}`}
>
{post.featuredImageUrl ? <img src={post.featuredImageUrl} alt="" loading="lazy" /> : null} {post.featuredImageUrl ? <img src={post.featuredImageUrl} alt="" loading="lazy" /> : null}
<div class="blog-card-body"> <div class="blog-card-body">
<h3 class="blog-card-title">{post.title}</h3> <h3 class="blog-card-title">{post.title}</h3>
<p class="blog-card-excerpt">{truncate(post.excerpt || "", 180)}</p> <p class="blog-card-excerpt">{truncate(post.excerpt || "", 180)}</p>
</div> </div>
</a> </a>

View File

@@ -10,16 +10,36 @@ const { categories, activeCategorySlug } = Astro.props;
--- ---
<nav class="subnav" aria-label="Blog categories"> <nav class="subnav" aria-label="Blog categories">
<a class={!activeCategorySlug ? "active" : ""} href="/blog"> <a
class={!activeCategorySlug ? "active" : ""}
href="/blog"
data-umami-event="click"
data-umami-event-target_id="blog.subnav.all"
data-umami-event-placement="blog.subnav"
data-umami-event-target_url="/blog"
>
All All
</a> </a>
<a class={activeCategorySlug === "__pages" ? "active" : ""} href="/blog/pages"> <a
class={activeCategorySlug === "__pages" ? "active" : ""}
href="/blog/pages"
data-umami-event="click"
data-umami-event-target_id="blog.subnav.pages"
data-umami-event-placement="blog.subnav"
data-umami-event-target_url="/blog/pages"
>
Pages Pages
</a> </a>
{categories.map((c) => ( {categories.map((c) => (
<a class={activeCategorySlug === c.slug ? "active" : ""} href={`/blog/category/${c.slug}`}> <a
class={activeCategorySlug === c.slug ? "active" : ""}
href={`/blog/category/${c.slug}`}
data-umami-event="click"
data-umami-event-target_id={`blog.subnav.category.${c.slug}`}
data-umami-event-placement="blog.subnav"
data-umami-event-target_url={`/blog/category/${c.slug}`}
>
{c.name} {c.name}
</a> </a>
))} ))}
</nav> </nav>

View File

@@ -44,7 +44,11 @@ if (!activeCategory) {
{posts.length > 0 ? ( {posts.length > 0 ? (
<div class="blog-grid"> <div class="blog-grid">
{posts.map((p) => ( {posts.map((p) => (
<BlogPostCard post={p} /> <BlogPostCard
post={p}
placement={`blog.category.${activeCategory.slug}`}
targetId={`blog.category.${activeCategory.slug}.card.post.${p.slug}`}
/>
))} ))}
</div> </div>
) : ( ) : (

View File

@@ -25,7 +25,7 @@ const pages = wordpressPages(cache);
{posts.length > 0 ? ( {posts.length > 0 ? (
<div class="blog-grid"> <div class="blog-grid">
{posts.map((p) => ( {posts.map((p) => (
<BlogPostCard post={p} /> <BlogPostCard post={p} placement="blog.index" targetId={`blog.index.card.post.${p.slug}`} />
))} ))}
</div> </div>
) : ( ) : (
@@ -37,18 +37,32 @@ const pages = wordpressPages(cache);
<section class="section"> <section class="section">
<div class="section-header"> <div class="section-header">
<h2>Pages</h2> <h2>Pages</h2>
<a class="muted" href="/blog/pages"> <a
class="muted"
href="/blog/pages"
data-umami-event="click"
data-umami-event-target_id="blog.index.pages.browse"
data-umami-event-placement="blog.index.pages_preview"
data-umami-event-target_url="/blog/pages"
>
Browse pages → Browse pages →
</a> </a>
</div> </div>
<div class="empty"> <div class="empty">
{pages.slice(0, 6).map((p) => ( {pages.slice(0, 6).map((p) => (
<div> <div>
<a href={`/blog/page/${p.slug}`}>{p.title}</a> <a
href={`/blog/page/${p.slug}`}
data-umami-event="click"
data-umami-event-target_id={`blog.index.pages.link.${p.slug}`}
data-umami-event-placement="blog.index.pages_preview"
data-umami-event-target_url={`/blog/page/${p.slug}`}
>
{p.title}
</a>
</div> </div>
))} ))}
</div> </div>
</section> </section>
) : null} ) : null}
</BlogLayout> </BlogLayout>

View File

@@ -33,7 +33,16 @@ const metaDescription = (page.excerpt || "").slice(0, 160);
<section class="section"> <section class="section">
<div class="section-header"> <div class="section-header">
<h2 style="margin: 0;">{page.title}</h2> <h2 style="margin: 0;">{page.title}</h2>
<a class="muted" href="/blog">Back →</a> <a
class="muted"
href="/blog"
data-umami-event="click"
data-umami-event-target_id="blog.page.back"
data-umami-event-placement="blog.page"
data-umami-event-target_url="/blog"
>
Back →
</a>
</div> </div>
{page.featuredImageUrl ? ( {page.featuredImageUrl ? (
<img <img
@@ -46,4 +55,3 @@ const metaDescription = (page.excerpt || "").slice(0, 160);
<div class="prose" set:html={page.contentHtml} /> <div class="prose" set:html={page.contentHtml} />
</section> </section>
</BlogLayout> </BlogLayout>

View File

@@ -25,7 +25,15 @@ const pages = wordpressPages(cache);
<div class="empty"> <div class="empty">
{pages.map((p) => ( {pages.map((p) => (
<div style="padding: 6px 0;"> <div style="padding: 6px 0;">
<a href={`/blog/page/${p.slug}`}>{p.title}</a> <a
href={`/blog/page/${p.slug}`}
data-umami-event="click"
data-umami-event-target_id={`blog.pages.link.${p.slug}`}
data-umami-event-placement="blog.pages.list"
data-umami-event-target_url={`/blog/page/${p.slug}`}
>
{p.title}
</a>
</div> </div>
))} ))}
</div> </div>
@@ -34,4 +42,3 @@ const pages = wordpressPages(cache);
)} )}
</section> </section>
</BlogLayout> </BlogLayout>

View File

@@ -33,7 +33,16 @@ const metaDescription = (post.excerpt || "").slice(0, 160);
<section class="section"> <section class="section">
<div class="section-header"> <div class="section-header">
<h2 style="margin: 0;">{post.title}</h2> <h2 style="margin: 0;">{post.title}</h2>
<a class="muted" href="/blog">Back →</a> <a
class="muted"
href="/blog"
data-umami-event="click"
data-umami-event-target_id="blog.post.back"
data-umami-event-placement="blog.post"
data-umami-event-target_url="/blog"
>
Back →
</a>
</div> </div>
<p class="muted" style="margin-top: 0;"> <p class="muted" style="margin-top: 0;">
{new Date(post.publishedAt).toLocaleDateString()} {new Date(post.publishedAt).toLocaleDateString()}
@@ -49,4 +58,3 @@ const metaDescription = (post.excerpt || "").slice(0, 160);
<div class="prose" set:html={post.contentHtml} /> <div class="prose" set:html={post.contentHtml} />
</section> </section>
</BlogLayout> </BlogLayout>

View File

@@ -0,0 +1,64 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { describe, expect, it } from "vitest";
async function read(rel: string) {
return await readFile(path.join(process.cwd(), rel), "utf8");
}
describe("blog umami event attributes", () => {
it("instruments blog secondary nav links", async () => {
const src = await read("src/components/BlogSecondaryNav.astro");
expect(src).toContain('data-umami-event="click"');
expect(src).toContain('data-umami-event-target_id="blog.subnav.all"');
expect(src).toContain('data-umami-event-target_id="blog.subnav.pages"');
expect(src).toContain("data-umami-event-target_id={`blog.subnav.category.${c.slug}`}");
expect(src).toContain('data-umami-event-placement="blog.subnav"');
expect(src).toContain("data-umami-event-target_url");
});
it("instruments blog post cards with deterministic target_id and placement", async () => {
const src = await read("src/components/BlogPostCard.astro");
expect(src).toContain('data-umami-event="click"');
expect(src).toContain("data-umami-event-target_id={targetId}");
expect(src).toContain("data-umami-event-placement={placement}");
expect(src).toContain("data-umami-event-target_url");
});
it("instruments blog pages list links (and keeps distinct IDs per placement)", async () => {
const indexSrc = await read("src/pages/blog/index.astro");
expect(indexSrc).toContain('data-umami-event-target_id="blog.index.pages.browse"');
expect(indexSrc).toContain("data-umami-event-target_id={`blog.index.pages.link.${p.slug}`}");
expect(indexSrc).toContain('data-umami-event-placement="blog.index.pages_preview"');
const pagesSrc = await read("src/pages/blog/pages.astro");
expect(pagesSrc).toContain("data-umami-event-target_id={`blog.pages.link.${p.slug}`}");
expect(pagesSrc).toContain('data-umami-event-placement="blog.pages.list"');
});
it("instruments blog detail back links", async () => {
const postSrc = await read("src/pages/blog/post/[slug].astro");
expect(postSrc).toContain('data-umami-event-target_id="blog.post.back"');
expect(postSrc).toContain('data-umami-event-placement="blog.post"');
expect(postSrc).toContain('data-umami-event-target_url="/blog"');
const pageSrc = await read("src/pages/blog/page/[slug].astro");
expect(pageSrc).toContain('data-umami-event-target_id="blog.page.back"');
expect(pageSrc).toContain('data-umami-event-placement="blog.page"');
expect(pageSrc).toContain('data-umami-event-target_url="/blog"');
});
it("uses placement-specific target_id for post cards", async () => {
const indexSrc = await read("src/pages/blog/index.astro");
expect(indexSrc).toContain("targetId={`blog.index.card.post.${p.slug}`}");
expect(indexSrc).toContain('placement="blog.index"');
const categorySrc = await read("src/pages/blog/category/[slug].astro");
expect(categorySrc).toContain("placement={`blog.category.${activeCategory.slug}`}");
expect(categorySrc).toContain("targetId={`blog.category.${activeCategory.slug}.card.post.${p.slug}`}");
});
});