Skip to content

Commit ef9ae71

Browse files
bloveclaude
andauthored
fix(website): fit meta descriptions to the search-snippet budget (#880)
GSC follow-up to #826: descriptions over ~160 chars get re-truncated by Google mid-sentence, and the docs fallback path hard-sliced the first paragraph at 177 chars mid-word with '...'. - New clampMetaDescription in site-metadata.ts: ≤160 chars, prefers a sentence boundary past 60% of the budget, otherwise cuts at a word boundary with a real ellipsis. Applied centrally in createPageMetadata and at the docs/blog description sources so each page's JSON-LD stays byte-identical to its meta description. - Docs extracted-paragraph path now uses the clamp (no more mid-word '...' cuts on the 111 pages without frontmatter descriptions). - Rewrote the 5 over-budget blog descriptions and the pricing page description by hand so the author-chosen copy fits without clamping. - Specs: clamp unit tests, a docs sweep (every page ≤160, no legacy '...', guarded against an empty slug list), and a real-content blog frontmatter gate in site-metadata.spec.ts (blog.spec.ts mocks fs). The clamp immediately caught the pricing page description losing its '$29 per developer per month' claim — shortened at the source instead. nx test green (371 passing), lint 0 errors, production build green. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 14ffe98 commit ef9ae71

11 files changed

Lines changed: 124 additions & 13 deletions

apps/website/content/blog/2026-05-21-build-fullstack-agentic-angular-apps-using-ag-ui.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
---
22
title: "Build Fullstack Agentic Angular Apps Using AG-UI"
3-
description: "A practical, signal-native walkthrough for wiring any AG-UI backend (LangGraph, CrewAI, Mastra, Pydantic AI, Microsoft Agent Framework) to a production Angular chat UI."
3+
description: "A practical walkthrough for wiring any AG-UI backend LangGraph, CrewAI, Mastra, Pydantic AI, and more — to a production Angular chat UI."
44
date: 2026-05-21
55
tags: [tutorial, ag-ui, angular, agents, langgraph]
66
author: brian

apps/website/content/blog/2026-05-28-human-in-the-loop-langgraph-agents-in-angular.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
---
22
title: "Human-in-the-Loop LangGraph Agents in Angular"
3-
description: "Build a human-in-the-loop LangGraph agent in Angular — pause runs before money moves with a structured approval dialog from @threadplane/chat and @threadplane/langgraph."
3+
description: "Build a human-in-the-loop LangGraph agent in Angular — pause runs before money moves with a structured approval dialog from the Threadplane libraries."
44
date: 2026-05-28
55
tags: [tutorial, langgraph, angular, agents, hitl, interrupts]
66
author: brian

apps/website/content/blog/2026-06-04-human-in-the-loop-ag-ui-agents-in-angular.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
---
22
title: "Human-in-the-Loop AG-UI Agents in Angular"
3-
description: "Build a human-in-the-loop AG-UI agent in Angular — the same chat-approval-card composition from the LangGraph version, wired to an AG-UI-fronted LangGraph backend via @threadplane/ag-ui."
3+
description: "Build a human-in-the-loop AG-UI agent in Angular — the same chat-approval-card composition as the LangGraph version, wired to AG-UI via @threadplane/ag-ui."
44
date: 2026-06-04
55
tags: [tutorial, ag-ui, angular, agents, hitl, interrupts]
66
author: brian

apps/website/content/blog/2026-06-24-threadplane-earns-grade-a-hvtrust.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
---
22
title: "Threadplane Earns a Grade A for Trust"
3-
description: "An independent tracker scored Threadplane's supply-chain trust at 82.8/100 — a Grade A, #7 of 75 agent frameworks, and the only Angular agent framework on the board. Here's the work behind the grade, and why it matters for agent frameworks specifically."
3+
description: "An independent tracker scored Threadplane's supply-chain trust at 82.8/100 — Grade A, #7 of 75 agent frameworks. The work behind the grade, and why it matters."
44
date: 2026-06-24
55
tags: [announcement, security, supply-chain, open-source, trust]
66
author: brian

apps/website/content/blog/2026-08-13-angular-chat-app-tutorial-with-ag-ui.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
---
22
title: 'Angular Chat App Tutorial with AG-UI'
3-
description: 'Build an Angular chat app on AG-UI where the browser owns its own tools — action, view, and ask client tools rendering real components inline, plus agent-shared state.'
3+
description: 'Build an Angular chat app on AG-UI where the browser owns its own tools — action, view, and ask tools rendering real components inline, plus shared state.'
44
date: 2026-08-13
55
tags: [tutorial, ag-ui, angular, client-tools, agentic-ui]
66
author: brian

apps/website/src/app/blog/[slug]/page.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { Eyebrow } from '../../../components/ui/Eyebrow';
88
import { getAllPosts, getPostBySlug, formatPostDate, readingTimeMin } from '../../../lib/blog';
99
import { getAuthor } from '../../../lib/blog-authors';
1010
import { extractHeadings } from '../../../lib/extract-headings';
11-
import { createPageMetadata, ogImagePath } from '../../../lib/site-metadata';
11+
import { clampMetaDescription, createPageMetadata, ogImagePath } from '../../../lib/site-metadata';
1212
import { getPostLastModified, publishedDate } from '../../../lib/sitemap-dates';
1313
import { JsonLd } from '../../../components/shared/JsonLd';
1414
import { blogPostingJsonLd, breadcrumbJsonLd } from '../../../lib/structured-data';
@@ -36,7 +36,7 @@ export async function generateMetadata({ params }: Params): Promise<Metadata> {
3636

3737
return createPageMetadata({
3838
title: `${post.frontmatter.title} — Threadplane`,
39-
description: post.frontmatter.description,
39+
description: clampMetaDescription(post.frontmatter.description),
4040
pathname,
4141
type: 'article',
4242
image: ogImagePath(post.slug),
@@ -72,7 +72,8 @@ export default async function BlogPostPage({ params }: Params) {
7272
const postData = published
7373
? blogPostingJsonLd({
7474
title: post.frontmatter.title,
75-
description: post.frontmatter.description,
75+
// Same clamp as the meta description so the two never diverge.
76+
description: clampMetaDescription(post.frontmatter.description),
7677
slug: post.slug,
7778
datePublished: published.toISOString(),
7879
dateModified: lastModified?.toISOString(),

apps/website/src/app/pricing/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { createPageMetadata } from '../../lib/site-metadata';
1212
export const metadata = createPageMetadata({
1313
title: 'Pricing — Threadplane',
1414
description:
15-
'Most Threadplane packages are MIT-licensed. @threadplane/chat is free for permitted noncommercial use and evaluation; commercial production plans start at $29 per developer per month. Threadplane runs in your own stack.',
15+
'Most Threadplane packages are MIT-licensed. @threadplane/chat plans start at $29 per developer per month. Threadplane runs in your own stack.',
1616
pathname: '/pricing',
1717
type: 'website',
1818
});

apps/website/src/lib/docs.spec.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,20 @@ describe('website docs bindings', () => {
118118
);
119119
});
120120

121+
it('keeps every description within the search-snippet budget', () => {
122+
// GSC follow-up to #826: 160+ char descriptions get re-truncated by
123+
// Google mid-sentence. The clamp must also never leave the legacy
124+
// mid-word '...' cut.
125+
const slugs = getAllDocSlugs();
126+
expect(slugs.length).toBeGreaterThan(100); // sweep must not pass on an empty list
127+
for (const { library, section, slug } of slugs) {
128+
const description = getDocMetadata(library, section, slug)?.description;
129+
expect(description, `${library}/${section}/${slug}`).toBeTruthy();
130+
expect(description!.length, `${library}/${section}/${slug}`).toBeLessThanOrEqual(160);
131+
expect(description!.endsWith('...'), `${library}/${section}/${slug}`).toBe(false);
132+
}
133+
});
134+
121135
it('derives mostly unique descriptions from page content', () => {
122136
const descriptions = getAllDocSlugs()
123137
.map(({ library, section, slug }) => getDocMetadata(library, section, slug)?.description)

apps/website/src/lib/docs.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import fs from 'fs';
22
import path from 'path';
33
import type { Metadata } from 'next';
44
import { docsConfig, type DocsPage, getLibraryConfig, getLibraryPages } from './docs-config';
5-
import { createPageMetadata } from './site-metadata';
5+
import { clampMetaDescription, createPageMetadata } from './site-metadata';
66

77
const resolveContentDir = (library: string): string => {
88
const workspacePath = path.join(process.cwd(), 'apps', 'website', 'content', 'docs', library);
@@ -79,16 +79,18 @@ function extractFirstParagraph(content: string): string | null {
7979
continue;
8080
}
8181

82-
return normalized.length > 180 ? `${normalized.slice(0, 177).trimEnd()}...` : normalized;
82+
return clampMetaDescription(normalized);
8383
}
8484

8585
return null;
8686
}
8787

8888
function getDocDescription(content: string, fallback: string): string {
89+
// Clamped here (not only in createPageMetadata) so the docs JSON-LD, which
90+
// reads this value directly, stays byte-identical to the meta description.
8991
const frontmatterDescription = readFrontmatterDescription(content);
90-
if (frontmatterDescription) return normalizeDescription(frontmatterDescription);
91-
return extractFirstParagraph(content) ?? fallback;
92+
if (frontmatterDescription) return clampMetaDescription(normalizeDescription(frontmatterDescription));
93+
return extractFirstParagraph(content) ?? clampMetaDescription(fallback);
9294
}
9395

9496
export function getDocBySlug(library: string, section: string, slug: string): ResolvedDoc | null {

apps/website/src/lib/site-metadata.spec.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import fs from 'node:fs';
22
import path from 'node:path';
33
import { describe, expect, it } from 'vitest';
44
import {
5+
clampMetaDescription,
56
DEFAULT_META_DESCRIPTION,
67
HERO_SUBHEAD,
78
LONG_SUBHEAD,
@@ -54,6 +55,49 @@ describe('site positioning copy', () => {
5455
});
5556
});
5657

58+
describe('clampMetaDescription', () => {
59+
it('passes short descriptions through untouched', () => {
60+
expect(clampMetaDescription('Short and sweet.')).toBe('Short and sweet.');
61+
});
62+
63+
it('collapses internal whitespace', () => {
64+
expect(clampMetaDescription(' a \n b ')).toBe('a b');
65+
});
66+
67+
it('prefers a sentence boundary once past 60% of the budget', () => {
68+
const first = 'This first sentence lands comfortably past the sixty percent mark of the one-sixty budget so the clamp should end exactly on it.';
69+
const result = clampMetaDescription(`${first} This trailing sentence pushes the whole thing well over the limit.`);
70+
expect(result).toBe(first);
71+
});
72+
73+
it('falls back to a word boundary with an ellipsis', () => {
74+
const words = Array.from({ length: 40 }, (_, i) => `word${i}`).join(' ');
75+
const result = clampMetaDescription(words);
76+
expect(result.length).toBeLessThanOrEqual(160);
77+
expect(result.endsWith('\u2026')).toBe(true);
78+
// never a mid-word cut: everything before the ellipsis is a whole word
79+
expect(words.startsWith(result.slice(0, -1))).toBe(true);
80+
expect(words[result.length - 1]).toBe(' ');
81+
});
82+
83+
it('never exceeds the budget', () => {
84+
const long = 'x'.repeat(400);
85+
expect(clampMetaDescription(long).length).toBeLessThanOrEqual(160);
86+
});
87+
});
88+
89+
describe('createPageMetadata description clamp', () => {
90+
it('clamps the meta and OpenGraph descriptions centrally', () => {
91+
const metadata = createPageMetadata({
92+
title: 'T',
93+
description: `${'Lead sentence that is deliberately made long enough to pass the sixty percent sentence-boundary threshold of the budget for this clamp.'} Overflow text beyond the snippet budget.`,
94+
pathname: '/x',
95+
});
96+
expect(String(metadata.description).length).toBeLessThanOrEqual(160);
97+
expect(metadata.openGraph?.description).toBe(metadata.description);
98+
});
99+
});
100+
57101
describe('createPageMetadata article fields', () => {
58102
it('emits openGraph article dates, authors, and tags', () => {
59103
const metadata = createPageMetadata({
@@ -159,3 +203,23 @@ describe('brand name', () => {
159203
expect(SITE_NAME).toBe('Threadplane');
160204
});
161205
});
206+
207+
describe('blog frontmatter description budget', () => {
208+
it('every post description fits a search snippet without clamping', () => {
209+
// The runtime clamp guards the meta tag, but a hand-written description
210+
// that needs clamping loses its author's chosen ending — keep the source
211+
// honest. (Reads the real content files; blog.spec.ts mocks fs.)
212+
const blogDir = path.join(resolveWebsiteDir(), 'content', 'blog');
213+
const offenders: string[] = [];
214+
for (const file of fs.readdirSync(blogDir).filter((f) => f.endsWith('.mdx'))) {
215+
const source = fs.readFileSync(path.join(blogDir, file), 'utf8');
216+
const description = source
217+
.match(/^---\n[\s\S]*?^description:\s*['"]?(?<d>[^'"\n]+?)['"]?\s*$/m)
218+
?.groups?.['d'];
219+
if (description && description.length > 160) {
220+
offenders.push(`${file} (${description.length} chars)`);
221+
}
222+
}
223+
expect(offenders).toEqual([]);
224+
});
225+
});

0 commit comments

Comments
 (0)