Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/astro.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,15 @@ export default defineConfig({
}),
],
title: "RocketSim Docs",
// Replace Starlight's default `og:site_name` (which mirrors `title`) with
// the umbrella brand name. Starlight dedupes entries in `head` by
// property/name, so this cleanly overrides instead of duplicating.
head: [
{
tag: "meta",
attrs: { property: "og:site_name", content: "RocketSim" },
},
],
disable404Route: true,
lastUpdated: true,
logo: {
Expand Down
130 changes: 123 additions & 7 deletions docs/src/components/starlight/Head.astro
Original file line number Diff line number Diff line change
@@ -1,13 +1,98 @@
---
/**
* Custom Head component for RocketSim docs
* Includes proper favicon and meta tags matching the main site
* Includes proper favicon, OG/Twitter image fallbacks, and a runtime
* BreadcrumbList JSON-LD for /docs/* routes.
*
* NOTE: Starlight's default Head already emits `<title>`, `<link rel="canonical">`,
* `og:title`, `og:type`, `og:description`, `og:url`, `og:locale`, `og:site_name`,
* `twitter:card`, `twitter:site`, and `<link rel="sitemap">`. The `og:site_name`
* override (to "RocketSim" rather than the Starlight site title "RocketSim Docs")
* lives in `astro.config.ts` under Starlight's `head` config so it deduplicates
* cleanly. This component only adds tags Starlight does not provide
* (image-related OG/Twitter tags) and the BreadcrumbList structured data.
*/
import Default from "@astrojs/starlight/components/Head.astro";
import PlausibleAnalytics from "@/components/PlausibleAnalytics.astro";
import config from "@/config/config.json";
import {
normalizeCanonicalUrl,
pathToAbsoluteUrl,
safeDecodePathSegment,
slugToTitle,
toAbsoluteSecureUrl,
} from "@/lib/utils/seo";

const ogImage = `${config.site.base_url}${config.metadata.meta_image}`;
// Dimensions/alt apply to the configured banner (`config.metadata.meta_image`,
// `public/og-banner-rocketsim.jpg`). They are only emitted when that banner is
// actually being served. The app-icon path is a separate last-resort fallback
// whose dimensions we deliberately don't claim at this layer.
const DEFAULT_BANNER_WIDTH = 1600;
const DEFAULT_BANNER_HEIGHT = 840;
const DEFAULT_BANNER_ALT = "RocketSim — build apps faster";
const APP_ICON_FALLBACK_PATH = "/images/rocketsim-app-icon.png";

const configuredBanner = toAbsoluteSecureUrl(config.metadata.meta_image);
const ogImage =
configuredBanner ??
toAbsoluteSecureUrl(APP_ICON_FALLBACK_PATH) ??
`${config.site.base_url}${APP_ICON_FALLBACK_PATH}`;
const isConfiguredBanner =
configuredBanner !== undefined && ogImage === configuredBanner;

const canonicalUrl = normalizeCanonicalUrl(Astro.url.href);

// Keep the encoded form for URL construction and a decoded form purely for
// human-readable breadcrumb names. Building hrefs from decoded segments would
// drop required percent-encoding (e.g. "%20" → literal space).
const rawPathSegments = Astro.url.pathname.split("/").filter(Boolean);
const decodedPathSegments = rawPathSegments.map(safeDecodePathSegment);
const currentSegment = decodedPathSegments[decodedPathSegments.length - 1];
const currentPageTitle =
Astro.locals.starlightRoute?.entry?.data?.title ??
(currentSegment ? slugToTitle(currentSegment) : "Docs");

// BreadcrumbList is scoped to docs routes only. Use a strict "/docs/" match so
// hypothetical paths like "/docs-something/" never trigger it.
const isDocsRoute =
Astro.url.pathname === "/docs" || Astro.url.pathname.startsWith("/docs/");

const breadcrumbItems = isDocsRoute
? [
{
"@type": "ListItem" as const,
position: 1,
name: "Home",
item: normalizeCanonicalUrl("/"),
},
{
"@type": "ListItem" as const,
position: 2,
name: "Docs",
item: pathToAbsoluteUrl(["docs"]),
},
...(rawPathSegments.length > 1
? [
{
"@type": "ListItem" as const,
position: 3,
name: currentPageTitle,
item: canonicalUrl,
},
]
: []),
]
: [];

const breadcrumbSchema =
breadcrumbItems.length > 1
? {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"@id": `${canonicalUrl}#breadcrumb`,
itemListElement: breadcrumbItems,
}
: null;
---

<Default {...Astro.props}><slot /></Default>
Expand All @@ -24,12 +109,43 @@ const ogImage = `${config.site.base_url}${config.metadata.meta_image}`;

<!-- Additional meta for better SEO -->
<meta name="author" content="RocketSim" />
<meta name="apple-mobile-web-app-title" content="RocketSim" />

<!-- Open Graph image for social sharing -->
<meta property="og:site_name" content="RocketSim" />
<!--
Open Graph / Twitter image tags. Starlight emits title/description/url/locale,
twitter:card and twitter:site itself, and `og:site_name` is set via Starlight's
`head` config in `astro.config.ts`, so we only fill in image gaps here.
-->
<meta property="og:image" content={ogImage} />
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:image" content={ogImage} />
<meta name="twitter:site" content="@rocketsim_app" />
<meta property="og:image:secure_url" content={ogImage} />
{
isConfiguredBanner && (
<>
<meta property="og:image:width" content={String(DEFAULT_BANNER_WIDTH)} />
<meta
property="og:image:height"
content={String(DEFAULT_BANNER_HEIGHT)}
/>
<meta property="og:image:type" content="image/jpeg" />
<meta property="og:image:alt" content={DEFAULT_BANNER_ALT} />
</>
)
}
<meta name="twitter:image" content={ogImage} />
{
isConfiguredBanner && (
<meta name="twitter:image:alt" content={DEFAULT_BANNER_ALT} />
)
}

{
breadcrumbSchema && (
<script
type="application/ld+json"
is:inline
set:html={JSON.stringify(breadcrumbSchema)}
/>
)
}

<PlausibleAnalytics />
63 changes: 52 additions & 11 deletions docs/src/layouts/components/SEO.astro
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
---
import config from "@/config/config.json";
import { normalizeCanonicalUrl, toAbsoluteSecureUrl } from "@/lib/utils/seo";

export interface Props {
title: string;
description?: string;
image?: string;
imageAlt?: string;
article?: {
publishedTime?: string;
modifiedTime?: string;
Expand All @@ -26,6 +28,7 @@ const {
title,
description,
image,
imageAlt,
article,
canonical,
robots,
Expand All @@ -35,16 +38,39 @@ const {

// Build canonical URL (normalize to trailing slash when the site is configured for it)
const rawCanonicalUrl = canonical || Astro.url.href;
const canonicalUrl =
config.site.trailing_slash && !rawCanonicalUrl.endsWith("/")
? `${rawCanonicalUrl}/`
: rawCanonicalUrl;
const canonicalUrl = normalizeCanonicalUrl(rawCanonicalUrl);

// Use provided description or fall back to default meta description
const metaDescription = description || config.metadata.meta_description;

// Use provided image or fall back to default meta image
const ogImage = image || `${config.site.base_url}${config.metadata.meta_image}`;
// Dimensions/alt apply to the configured banner (`config.metadata.meta_image`,
// `public/og-banner-rocketsim.jpg`). They are only emitted when that banner is
// actually being served. The app-icon path is a separate last-resort fallback
// whose dimensions we deliberately don't claim at this layer.
const DEFAULT_BANNER_WIDTH = 1600;
const DEFAULT_BANNER_HEIGHT = 840;
const DEFAULT_BANNER_ALT = "RocketSim — build apps faster";
const APP_ICON_FALLBACK_PATH = "/images/rocketsim-app-icon.png";

// Use provided image or fall back to default meta image; resolve to an absolute,
// https-only URL so social crawlers always receive a fully-qualified asset.
const providedImage = toAbsoluteSecureUrl(image);
const configuredImage = toAbsoluteSecureUrl(config.metadata.meta_image);
const ogImage =
providedImage ??
configuredImage ??
toAbsoluteSecureUrl(APP_ICON_FALLBACK_PATH) ??
`${config.site.base_url}${APP_ICON_FALLBACK_PATH}`;

// Only emit width/height/type when the configured banner is the one we're
// actually serving. We don't know dimensions for author-supplied images or for
// the app-icon fallback.
const isConfiguredBanner =
!providedImage &&
configuredImage !== undefined &&
ogImage === configuredImage;
const ogImageAlt =
imageAlt ?? (isConfiguredBanner ? DEFAULT_BANNER_ALT : title);

// Build robots meta content
let robotsContent = "";
Expand Down Expand Up @@ -90,6 +116,20 @@ const ogType = article ? "article" : "website";
<meta property="og:description" content={metaDescription} />
<meta property="og:site_name" content="RocketSim" />
<meta property="og:image" content={ogImage} />
<meta property="og:image:secure_url" content={ogImage} />
<meta property="og:image:alt" content={ogImageAlt} />
{
isConfiguredBanner && (
<>
<meta property="og:image:width" content={String(DEFAULT_BANNER_WIDTH)} />
<meta
property="og:image:height"
content={String(DEFAULT_BANNER_HEIGHT)}
/>
<meta property="og:image:type" content="image/jpeg" />
</>
)
}

<!-- Article-specific Open Graph tags -->
{
Expand All @@ -110,10 +150,11 @@ const ogType = article ? "article" : "website";
}

<!-- Twitter -->
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:url" content={canonicalUrl} />
<meta property="twitter:title" content={title} />
<meta property="twitter:description" content={metaDescription} />
<meta property="twitter:image" content={ogImage} />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:url" content={canonicalUrl} />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={metaDescription} />
<meta name="twitter:image" content={ogImage} />
Comment thread
AvdLee marked this conversation as resolved.
<meta name="twitter:image:alt" content={ogImageAlt} />
<meta name="twitter:creator" content={twitterCreator} />
<meta name="twitter:site" content={twitterSite} />
Loading
Loading