open-design/apps/landing-page/app/_components/seo-head.astro
lefarcen 7312c64580
ci(landing): split landing deploy into staging gate + manual production (#2994)
* ci(landing): split landing deploy into staging gate + manual production

A merge to `main` previously published the landing page straight to
production (open-design.ai) via `landing-page-deploy`. There was no
buffer to review the rendered site, so a bad merge was live instantly.

Split deploys across two Cloudflare Pages projects so production is only
ever reached by an explicit human action:

- `landing-page-staging` (push to main) -> staging project
  `open-design-landing-staging` -> staging.open-design.ai.
- `landing-page-production` (manual workflow_dispatch only) -> production
  project `open-design-landing` -> open-design.ai. Only this workflow
  names the production project; gate it with required reviewers on the
  `production` GitHub environment.
- `landing-page-ci` now also deploys a per-PR preview into the staging
  project (`--branch=pr-<n>`) for same-repo branches and comments the URL.
  Fork PRs (no secrets / read-only token) skip the deploy and keep just
  the build validation. Path filters already scope this to landing edits.

Decouple search-engine indexing from staging:

- `blog-indexing-on-deploy` now triggers on `landing-page-production`
  (not every main push), so the test environment is never submitted to
  Google/IndexNow.
- It diffs from a new `blog-indexed-prod` tag (the last indexed prod
  commit) instead of `HEAD^`, and force-advances the tag after a
  successful run, so a manual promotion bundling several merged posts
  indexes all of them rather than only the last commit.

Staging and PR-preview builds drop `PUBLIC_GA_MEASUREMENT_ID` so test
traffic does not pollute the production GA property.

* ci(landing): keep staging + PR previews out of the search index

staging.open-design.ai mirrors production and is exposed via cert
transparency logs, so search engines can discover it. Indexing the
mirror competes with open-design.ai for the same content.

Emit `<meta name="robots" content="noindex, nofollow">` whenever
OD_LANDING_NOINDEX=1, and set that flag on the staging and PR-preview
builds (production leaves it unset and stays indexable). noindex is
used rather than a robots.txt Disallow so crawlers can still fetch the
page and read both the tag and the canonical, which already points at
the production origin.

* fix(landing): make staging noindex actually take effect

The previous commit read `process.env.OD_LANDING_NOINDEX` directly in
`seo-head.astro`, but `.astro` frontmatter is transformed by Vite and
does not see process.env, so the meta never rendered. Two fixes:

- Inject the flag as the compile-time constant `__OD_LANDING_NOINDEX__`
  via `vite.define` in astro.config.ts (config runs in Node and can read
  process.env); SeoHead consumes that constant.
- The homepage (`index.astro`) and `og.astro` build their own <head> and
  never use SeoHead, so a per-component meta can miss pages. Add an
  `astro:build:done` integration that appends a catch-all
  `/*  X-Robots-Tag: noindex, nofollow` to the Cloudflare Pages `_headers`
  on staging/preview builds, covering every response (homepage, assets,
  any custom-head page) at the HTTP layer. Production builds leave
  `_headers` untouched.

Verified: build with OD_LANDING_NOINDEX=1 emits the _headers block and
the SeoHead <meta>; build without the flag emits neither; astro check
clean.

* fix(landing): address review — pin prod checkout to main, defer index pointer

Two blockers from review:

- landing-page-production: workflow_dispatch can be launched from any ref
  via the Actions "Use workflow from" dropdown, so an operator could ship
  an arbitrary branch to open-design.ai. Pin the checkout to `ref: main`
  so the deployed artifact always equals reviewed main.

- blog-indexing-on-deploy: the `blog-indexed-prod` pointer was advanced
  right after sitemap submission, before Inspect / Search Analytics /
  Render status / Open status PR. A failure in any of those still moved
  the pointer, so the next production run skipped those posts. Move the
  advance to the very end, gated on `success()`, so a failure leaves the
  tag in place and the range is re-processed next run (submissions are
  idempotent).

* fix(landing): gate production promotion to the main ref only

Follow-up to the production-path review note: pinning checkout to main
fixed the deployed content, but the workflow was still dispatchable from
any ref, which records a non-main production run and would dodge
blog-indexing's `workflow_run` `branches: [main]` filter. Gate the whole
job on `github.ref == 'refs/heads/main'` so a dispatch from any other
branch/tag is skipped outright.
2026-05-26 14:05:04 +00:00

211 lines
7.5 KiB
Text
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
/*
* SeoHead — single source of truth for per-page metadata.
*
* Usage in any Astro page's <head>:
*
* <SeoHead kind="website" title="..." description="..." pathname={Astro.url.pathname} />
* <SeoHead kind="article" title={post.data.title} description={post.data.summary}
* pathname={Astro.url.pathname} datePublished={post.data.date}
* category={post.data.category} />
*
* The component does NOT render <meta charset> or <meta viewport> — those
* stay in the page so they appear before any module imports. SeoHead handles
* everything else: <title>, description, canonical, OpenGraph, Twitter,
* theme-color, and (for articles) Article JSON-LD.
*/
import { ogDefaultImage } from '../image-assets';
import {
LANDING_LOCALES,
alternateLinksForPath,
getLocaleDefinition,
localeFromPath,
stripLocaleFromPath,
} from '../i18n';
import FaviconLinks from './favicon-links.astro';
import ResourceHints from './resource-hints.astro';
export interface SeoHeadProps {
/** 'website' for landing/list pages, 'article' for blog posts. */
kind: 'website' | 'article';
/** For article: raw post title (component appends " — Open Design"). For website: the full title string. */
title: string;
/** Meta description, 150160 chars recommended. */
description: string;
/** Pass `Astro.url.pathname`. */
pathname: string;
/** Optional override for the OG/Twitter card image (absolute URL). */
image?: string;
/** Required for kind: 'article'. */
datePublished?: Date;
dateModified?: Date;
/** Required for kind: 'article'. e.g. "Guides". */
category?: string;
/** Defaults to "Open Design". */
author?: string;
/**
* Optional Google Search Console verification token. When set, renders
* `<meta name="google-site-verification" content="...">`. Only needed on
* one page (typically `/`) for URL-prefix property verification; the
* Domain property type uses DNS TXT instead and doesn't need this.
*/
googleSiteVerification?: string;
}
const props = Astro.props as SeoHeadProps;
// Staging / PR-preview builds set OD_LANDING_NOINDEX=1 so the mirror at
// staging.open-design.ai (exposed via certificate-transparency logs) is kept
// out of the search index. We emit `noindex` rather than a robots.txt
// `Disallow` so crawlers can still fetch the page and read both this tag and
// the canonical (which points at the production origin). Production builds
// leave the flag unset and stay fully indexable.
//
// `__OD_LANDING_NOINDEX__` is a compile-time constant injected by
// `vite.define` in astro.config.ts — `.astro` frontmatter is transformed by
// Vite and cannot read process.env directly.
const noindex = __OD_LANDING_NOINDEX__;
const SITE_NAME = 'Open Design';
const TAGLINE = 'Design with the agent already on your laptop.';
const isArticle = props.kind === 'article';
const locale = localeFromPath(props.pathname);
const localeDef = getLocaleDefinition(locale);
const basePath = stripLocaleFromPath(props.pathname).pathname;
const alternateLinks = alternateLinksForPath(props.pathname).map((entry) => ({
...entry,
href: new URL(entry.hrefPath, Astro.site).toString(),
}));
const xDefaultHref = new URL(alternateLinks[0]!.hrefPath, Astro.site).toString();
const canonical = new URL(props.pathname, Astro.site).toString();
const image = props.image ?? new URL(ogDefaultImage, Astro.site).toString();
const rssUrl = new URL('/blog/rss.xml', Astro.site).toString();
const fullTitle = isArticle ? `${props.title} — ${SITE_NAME}` : props.title;
const ogTitle = props.title;
const author = props.author ?? SITE_NAME;
const isoPublished =
isArticle && props.datePublished ? props.datePublished.toISOString() : null;
const isoModified =
isArticle
? (props.dateModified ?? props.datePublished)?.toISOString() ?? null
: null;
const articleJsonLd =
isArticle && isoPublished
? {
'@context': 'https://schema.org',
'@type': 'Article',
headline: props.title,
description: props.description,
inLanguage: localeDef.htmlLang,
image: [image],
datePublished: isoPublished,
dateModified: isoModified,
articleSection: props.category,
author: {
'@type': 'Organization',
name: author,
url: Astro.site?.toString() ?? 'https://open-design.ai/',
},
publisher: {
'@type': 'Organization',
name: SITE_NAME,
url: Astro.site?.toString() ?? 'https://open-design.ai/',
logo: {
'@type': 'ImageObject',
url: image,
},
},
mainEntityOfPage: {
'@type': 'WebPage',
'@id': canonical,
},
}
: null;
const websiteJsonLd =
props.kind === 'website' && basePath === '/'
? {
'@context': 'https://schema.org',
'@type': 'WebSite',
name: SITE_NAME,
alternateName: TAGLINE,
url: Astro.site?.toString() ?? 'https://open-design.ai/',
inLanguage: localeDef.htmlLang,
availableLanguage: LANDING_LOCALES.map((entry) => entry.htmlLang),
}
: null;
const blogJsonLd =
props.kind === 'website' && basePath === '/blog/'
? {
'@context': 'https://schema.org',
'@type': 'Blog',
name: 'Open Design — Blog',
description: props.description,
url: canonical,
inLanguage: localeDef.htmlLang,
publisher: {
'@type': 'Organization',
name: SITE_NAME,
url: Astro.site?.toString() ?? 'https://open-design.ai/',
},
}
: null;
---
<title>{fullTitle}</title>
<meta name='description' content={props.description} />
<meta name='theme-color' content='#efe7d2' />
{noindex && <meta name='robots' content='noindex, nofollow' />}
<link rel='canonical' href={canonical} />
{alternateLinks.map((entry) => (
<link rel='alternate' hreflang={entry.hreflang} href={entry.href} />
))}
<link rel='alternate' hreflang='x-default' href={xDefaultHref} />
<link rel='alternate' type='application/rss+xml' title='Open Design Blog' href={rssUrl} />
<FaviconLinks />
<ResourceHints />
{props.googleSiteVerification && (
<meta name='google-site-verification' content={props.googleSiteVerification} />
)}
<meta property='og:type' content={props.kind === 'article' ? 'article' : 'website'} />
<meta property='og:site_name' content={SITE_NAME} />
<meta property='og:title' content={ogTitle} />
<meta property='og:description' content={props.description} />
<meta property='og:url' content={canonical} />
<meta property='og:image' content={image} />
<meta property='og:locale' content={localeDef.ogLocale} />
{LANDING_LOCALES.filter((entry) => entry.code !== locale).map((entry) => (
<meta property='og:locale:alternate' content={entry.ogLocale} />
))}
{props.kind === 'article' && (
<>
<meta property='article:published_time' content={isoPublished!} />
<meta property='article:modified_time' content={isoModified!} />
<meta property='article:section' content={props.category} />
<meta property='article:author' content={author} />
</>
)}
<meta name='twitter:card' content='summary_large_image' />
<meta name='twitter:title' content={ogTitle} />
<meta name='twitter:description' content={props.description} />
<meta name='twitter:image' content={image} />
{articleJsonLd && (
<script is:inline type='application/ld+json' set:html={JSON.stringify(articleJsonLd)} />
)}
{websiteJsonLd && (
<script is:inline type='application/ld+json' set:html={JSON.stringify(websiteJsonLd)} />
)}
{blogJsonLd && (
<script is:inline type='application/ld+json' set:html={JSON.stringify(blogJsonLd)} />
)}