feat(blog): orchestration architecture post + hero blog page layout (ZE-1246)#168
feat(blog): orchestration architecture post + hero blog page layout (ZE-1246)#168
Conversation
Adds full blog post "Why Orchestration is the Missing Layer in Your Deployment Stack" targeting enterprise C-level readers as part of the Zephyr Orchestration Architecture marketing campaign. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
🚀 Preview Environment Ready!
Details:
|
…hestration post (ZE-1246) - Add custom dark-theme SVG hero image with 3-stage pipeline diagram - Add Vern Tremble as author with avatar - Wire hero image into images.ts and loader.ts authorMap - Update post frontmatter: author → Vern Tremble Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds dynamic document head management to the blog post route: - document.title: "[Post Title] | Zephyr Cloud" - meta description, og:title, og:description, og:image, og:type, og:url - twitter:card, twitter:title, twitter:description, twitter:image - canonical link element Fixes Lighthouse "Document does not have a meta description" for all blog posts. Cleans up title on unmount. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… (ZE-1246) Internal links: - /blog/module-federation-vs-native-esm — micro-frontend architectures - /blog/whos-your-cloud-daddy — CDN vendor lock-in - /blog/cloudflare-workers-aws-outage — single-provider outage risk - /blog/three-sdlcs-one-zephyr — tag/environment routing workflows - /blog/all-the-pipelines — multi-CDN publishing mechanics - /blog/serve-time — edge serving / Serve Time concept - /blog/aws-byoc — AWS as CDN provider - /blog/soc2 — compliance and audit trail - /blog/dora-metrics — deployment frequency and DORA research External links: - kubernetes.io — Kubernetes deployment docs - aws.amazon.com/ecs — AWS ECS - workers.cloudflare.com — Cloudflare Workers - rspack.dev — Rspack bundler - docs.zephyr-cloud.io/general/getting-started — supported toolchains - akamai.com, fastly.com, netlify.com — CDN providers - dora.dev/research — DORA research on elite engineering teams - module-federation.io — Module Federation Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…rnal MDX links Updates the global MDX <a> component to detect external URLs (http/https) and automatically apply target="_blank" rel="noopener noreferrer". Internal links (relative paths) are unaffected. Applies site-wide to all blog posts via the MDXProvider in __root.tsx. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds a new orchestration architecture blog post and updates the blog UI/MDX rendering to support a hero featured post, improved outbound link handling, and per-post SEO meta tags.
Changes:
- Updates
/blogto render a single hero post followed by a “Latest Posts” grid. - Injects per-post SEO meta tags/canonical URL on blog post pages.
- Adds new post/author assets and registers the post + author in the blog loader.
Reviewed changes
Copilot reviewed 8 out of 11 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| src/routes/blog/index.tsx | Switches blog listing to hero + latest grid layout logic. |
| src/routes/blog/$slug.tsx | Adds client-side meta tag + canonical injection based on post content. |
| src/routes/__root.tsx | Updates MDX <a> to open external links in a new tab safely. |
| src/lib/blog/loader.ts | Registers new author mapping and blog post module loader entry. |
| src/lib/blog/images.ts | Registers hero/listing image for the new orchestration post. |
| src/images/blog/orchestration-architecture/hero.svg | Adds new hero SVG asset for the orchestration post. |
| src/images/authors/vern.svg | Adds new author avatar SVG asset. |
| src/data/blog/authors.ts | Adds new author entry for Vern (avatar + socials). |
| src/content/blog/orchestration-architecture.mdx | Adds new orchestration architecture blog post content. |
| src/components/BlogCard.tsx | Introduces a new featured/hero BlogCard layout. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| useEffect(() => { | ||
| if (!post) return; | ||
|
|
||
| const prevTitle = document.title; | ||
| document.title = `${post.title} | Zephyr Cloud`; | ||
|
|
||
| const setMeta = (selector: string, attr: string, value: string) => { | ||
| let el = document.querySelector<HTMLMetaElement>(selector); | ||
| if (!el) { | ||
| el = document.createElement('meta'); | ||
| const [attrName, attrValue] = selector.replace('meta[', '').replace(']', '').split('='); | ||
| el.setAttribute(attrName, attrValue.replace(/"/g, '')); | ||
| document.head.appendChild(el); | ||
| } | ||
| el.setAttribute(attr, value); | ||
| }; | ||
|
|
||
| const origin = typeof window !== 'undefined' ? window.location.origin : 'https://zephyr-cloud.io'; | ||
| const url = `${origin}/blog/${post.slug}`; | ||
| const heroImage = typeof post.heroImage === 'string' ? post.heroImage : ''; | ||
|
|
||
| setMeta('meta[name="description"]', 'content', post.description); | ||
| setMeta('meta[property="og:type"]', 'content', 'article'); | ||
| setMeta('meta[property="og:title"]', 'content', post.title); | ||
| setMeta('meta[property="og:description"]', 'content', post.description); | ||
| setMeta('meta[property="og:url"]', 'content', url); | ||
| if (heroImage) setMeta('meta[property="og:image"]', 'content', heroImage); | ||
| setMeta('meta[name="twitter:card"]', 'content', 'summary_large_image'); | ||
| setMeta('meta[name="twitter:title"]', 'content', post.title); | ||
| setMeta('meta[name="twitter:description"]', 'content', post.description); | ||
| if (heroImage) setMeta('meta[name="twitter:image"]', 'content', heroImage); | ||
|
|
||
| let canonical = document.querySelector<HTMLLinkElement>('link[rel="canonical"]'); | ||
| if (!canonical) { | ||
| canonical = document.createElement('link'); | ||
| canonical.rel = 'canonical'; | ||
| document.head.appendChild(canonical); | ||
| } | ||
| canonical.href = url; | ||
|
|
||
| return () => { | ||
| document.title = prevTitle; | ||
| }; | ||
| }, [post]); |
There was a problem hiding this comment.
Meta tags/canonical are mutated but not restored on cleanup, and og:image / twitter:image are only set when heroImage is truthy. Navigating from a post with an image to one without (or to a non-post route) will leave stale image/canonical tags in <head>. Track previous values (or elements created) and restore/remove them in the effect cleanup; also explicitly remove or blank og:image + twitter:image when heroImage is empty.
|
|
||
| const origin = typeof window !== 'undefined' ? window.location.origin : 'https://zephyr-cloud.io'; | ||
| const url = `${origin}/blog/${post.slug}`; | ||
| const heroImage = typeof post.heroImage === 'string' ? post.heroImage : ''; |
There was a problem hiding this comment.
Open Graph/Twitter image URLs are typically expected to be absolute (many scrapers won’t reliably resolve relative URLs). If post.heroImage is a relative asset URL, build an absolute URL using origin (e.g., only prepend when it starts with /) before setting og:image / twitter:image.
| const heroImage = typeof post.heroImage === 'string' ? post.heroImage : ''; | |
| const rawHeroImage = typeof post.heroImage === 'string' ? post.heroImage : ''; | |
| const heroImage = rawHeroImage.startsWith('/') ? `${origin}${rawHeroImage}` : rawHeroImage; |
| setMeta('meta[property="og:title"]', 'content', post.title); | ||
| setMeta('meta[property="og:description"]', 'content', post.description); | ||
| setMeta('meta[property="og:url"]', 'content', url); | ||
| if (heroImage) setMeta('meta[property="og:image"]', 'content', heroImage); |
There was a problem hiding this comment.
Open Graph/Twitter image URLs are typically expected to be absolute (many scrapers won’t reliably resolve relative URLs). If post.heroImage is a relative asset URL, build an absolute URL using origin (e.g., only prepend when it starts with /) before setting og:image / twitter:image.
| setMeta('meta[name="twitter:card"]', 'content', 'summary_large_image'); | ||
| setMeta('meta[name="twitter:title"]', 'content', post.title); | ||
| setMeta('meta[name="twitter:description"]', 'content', post.description); | ||
| if (heroImage) setMeta('meta[name="twitter:image"]', 'content', heroImage); |
There was a problem hiding this comment.
Open Graph/Twitter image URLs are typically expected to be absolute (many scrapers won’t reliably resolve relative URLs). If post.heroImage is a relative asset URL, build an absolute URL using origin (e.g., only prepend when it starts with /) before setting og:image / twitter:image.
| return ( | ||
| <a | ||
| href={href} | ||
| className="text-emerald-400 hover:text-emerald-300 underline" | ||
| {...(isExternal ? { target: '_blank', rel: 'noopener noreferrer' } : {})} | ||
| {...props} |
There was a problem hiding this comment.
{...props} is spread after the external-link target/rel, which allows MDX-provided props to override them (e.g., removing rel=\"noopener noreferrer\"). To ensure the security attributes can’t be overridden, spread props first and then enforce/merge target + rel for external links (ensuring noopener noreferrer is preserved if a custom rel is provided).
| return ( | |
| <a | |
| href={href} | |
| className="text-emerald-400 hover:text-emerald-300 underline" | |
| {...(isExternal ? { target: '_blank', rel: 'noopener noreferrer' } : {})} | |
| {...props} | |
| const externalAttrs = isExternal | |
| ? { | |
| target: '_blank', | |
| rel: Array.from( | |
| new Set( | |
| ['noopener', 'noreferrer'] | |
| .concat( | |
| typeof props.rel === 'string' | |
| ? props.rel.split(/\s+/).filter(Boolean) | |
| : [], | |
| ), | |
| ), | |
| ).join(' '), | |
| } | |
| : {}; | |
| return ( | |
| <a | |
| href={href} | |
| className="text-emerald-400 hover:text-emerald-300 underline" | |
| {...props} | |
| {...externalAttrs} |
| @@ -1,4 +1,5 @@ | |||
| import loisAvatar from '@/images/authors/lois.webp'; | |||
| import vernAvatar from '@/images/authors/vern.webp'; | |||
There was a problem hiding this comment.
This imports vern.webp, but the PR adds src/images/authors/vern.svg (not a .webp). This will fail at build time unless vern.webp exists elsewhere. Either add the expected vern.webp asset or update the import to the actual file type/path being added.
| import vernAvatar from '@/images/authors/vern.webp'; | |
| import vernAvatar from '@/images/authors/vern.svg'; |
| ); | ||
| // First featured post is the hero; everything else follows as latest | ||
| const featuredPost = filteredPosts.find((post) => post.featured) ?? filteredPosts[0]; | ||
| const latestPosts = filteredPosts.filter((post) => post !== featuredPost); |
There was a problem hiding this comment.
Filtering latestPosts by object identity (post !== featuredPost) is brittle if posts are ever re-hydrated/cloned (identity changes) or if a similar object reference is used. Prefer filtering by a stable identifier (e.g., slug) and handle the undefined case explicitly.
| const latestPosts = filteredPosts.filter((post) => post !== featuredPost); | |
| const featuredPostSlug = featuredPost?.slug; | |
| const latestPosts = | |
| featuredPostSlug != null | |
| ? filteredPosts.filter((post) => post.slug !== featuredPostSlug) | |
| : filteredPosts; |
| <div className="flex -space-x-2"> | ||
| {post.authors?.map((author, index) => ( | ||
| <img | ||
| key={index} | ||
| src={author.avatar} | ||
| alt={author.displayName} | ||
| className="w-9 h-9 rounded-full ring-2 ring-neutral-900" | ||
| /> | ||
| ))} | ||
| </div> |
There was a problem hiding this comment.
Using index as a React key can cause incorrect UI reconciliation if the authors array changes order or items are inserted/removed. Use a stable key such as author.displayName (or another unique author id/link) instead.
There was a problem hiding this comment.
Maybe we should regenerate this image. Seems a bit broken
What's added in this PR?
target=_blankandrel=noopener noreferrerWhat's the issue related to this PR?
ZE-1246
How to test this PR?
/blog— confirm the hero post renders as a wide horizontal card at the top<head>Checklist