feat(landing-page): strengthen the vibe-design pillar + hub-and-spoke internal links (#5003)

* feat(landing-page): strengthen the vibe-design pillar + wire hub-and-spoke links

The 4 vibe-design blog posts were splitting the bare "vibe design" query
across each other (GSC: 3 pages clustered at pos 5-6 in the same locale),
and the pillar (what-is-vibe-design) under-competed the top-ranking guides
(Visily ~2,200 words + FAQ + decision matrix; Cerence ~2,100).

Upgrade what-is-vibe-design into a proper pillar:
- Add a "When vibe design is the right call — and when it isn't" decision
  section and a 7-question FAQ (PAA / featured-snippet surface), taking the
  body from ~1,150 to ~1,860 words.
- Complete the hub-and-spoke: the pillar now links out to all three cluster
  posts (vibe-design-tools, vibe-design-vs-vibe-coding, vibe-design-with-stitch)
  in-context, consolidating topical authority on the pillar and clarifying to
  search engines which page owns the head term.
- Sharpen the differentiator competitors miss — prompt to shipped code,
  open-source, agent-native — since Visily stops at a mockup and Cerence is an
  interview.

Follow-up: re-translate the localized bodyHtml (keep-11 locales) to match.
Locale pages keep their prior full translation until then (not broken, just
one version behind).

* fix(landing-page): keep vibe-design readingTime aligned with translated bodies

readingTime is a shared field the blog template renders for every locale.
Since this PR intentionally leaves the localized bodyHtml on the shorter
prior copy until the translation follow-up, keep readingTime at 6 so the
10 localized pages stay accurate; it moves to 9 in the localization PR when
every body grows to match the expanded English article.

* feat(landing-page): per-locale blog readingTime override

A shared readingTime can't be right for both the expanded English body and
the localized bodyHtml still on the shorter prior translation (reviewer:
9 overstates the localized pages, 6 understates English). Add an optional
per-locale readingTime to the blog i18n schema, render it in [slug].astro
when present, set the English body to 9, and pin the ten rendered locales
to 6 until the localization follow-up grows their bodies. Verified: EN shows
9 min, ja/de/es show 6.

* fix(landing-page): use per-locale readingTime on the blog index too

The detail page honored the per-locale readingTime override, but the blog
index (and its /[locale]/ wrapper) still rendered the shared English value
on both the featured and regular cards. Apply the same locale-aware lookup
there so non-English index cards match their article. Verified: EN index
card shows 9 min, JA shows 6.

---------

Co-authored-by: Joey <276262049+xne998808-ai@users.noreply.github.com>
This commit is contained in:
xne998808-ai
2026-07-01 09:59:03 +00:00
committed by GitHub
co-authored by Joey
parent d270b401f2
commit b1d4e59003
4 changed files with 81 additions and 5 deletions
+6
View File
@@ -140,6 +140,12 @@ const blog = defineCollection({
category: z.string().optional(),
body: z.string().optional(),
bodyHtml: z.string().optional(),
// Optional per-locale reading time. Set this when a localized
// `bodyHtml` differs in length from the English Markdown (e.g. a
// translation that hasn't caught up to an expanded English body)
// so non-English readers see an accurate estimate instead of the
// shared English `readingTime`.
readingTime: z.number().int().positive().optional(),
})
.passthrough(),
)
File diff suppressed because one or more lines are too long
@@ -91,6 +91,13 @@ const localizedBodyHtml =
) ??
fallbackPost.bodyHtml;
// A localized bodyHtml can differ in length from the English Markdown; prefer a
// per-locale readingTime when one is set so the estimate matches what the reader
// actually sees, and fall back to the shared English value otherwise.
const localizedReadingTime =
(localizedPostI18n as { readingTime?: number } | undefined)?.readingTime ??
post.data.readingTime;
// Build the table of contents. For pre-rendered bodyHtml (rich/localized
// posts) inject heading ids and collect the TOC in one pass; for the Markdown
// `<Content />` path use Astro's already-slugged `headings` array.
@@ -260,7 +267,7 @@ const author = getBlogAuthor((post.data as { author?: string }).author);
<div class='post-meta'>
<span>{fmtDate(post.data.date)}</span>
<span>{post.data.readingTime} {ui.blog.minRead}</span>
<span>{localizedReadingTime} {ui.blog.minRead}</span>
<span>{localizedPostField('category')}</span>
</div>
+11 -2
View File
@@ -192,6 +192,15 @@ const postImages: Record<string, { src: string; alt: string }> = {
},
};
const getPostImage = (id: string) => postImages[id];
// Prefer a per-locale readingTime when a localized bodyHtml differs in length
// from the English body (mirrors the detail page in `[slug].astro`), so cards
// on non-English blog indexes show an accurate estimate.
const localizedReadingTime = (post: (typeof posts)[number]) =>
(
post.data as typeof post.data & {
i18n?: Record<string, { readingTime?: number }>;
}
).i18n?.[locale]?.readingTime ?? post.data.readingTime;
const localizedPostField = (
post: (typeof posts)[number],
field: 'title' | 'summary' | 'category',
@@ -283,7 +292,7 @@ const localizedPostField = (
<span class='blog-feature-meta'>
<span>{fmtDate(featuredPost.data.date)}</span>
<span>·</span>
<span>{featuredPost.data.readingTime} {ui.blog.minRead}</span>
<span>{localizedReadingTime(featuredPost)} {ui.blog.minRead}</span>
<span class='blog-feature-cta'>{ui.blog.readMore}</span>
</span>
</span>
@@ -349,7 +358,7 @@ const localizedPostField = (
<p class='blog-card-summary'>{localizedPostField(post, 'summary')}</p>
<span class='blog-card-foot'>
<span class='blog-card-date'>{fmtDate(post.data.date)}</span>
<span class='blog-card-rt'>{post.data.readingTime} {ui.blog.minRead}</span>
<span class='blog-card-rt'>{localizedReadingTime(post)} {ui.blog.minRead}</span>
<span class='blog-card-cta'>{ui.blog.read}</span>
</span>
</span>