mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
* 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.
211 lines
7.5 KiB
Text
211 lines
7.5 KiB
Text
---
|
||
/*
|
||
* 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, 150–160 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)} />
|
||
)}
|