diff --git a/docs/astro.config.ts b/docs/astro.config.ts
index 1aa43f0d..20dd20a0 100644
--- a/docs/astro.config.ts
+++ b/docs/astro.config.ts
@@ -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: {
diff --git a/docs/src/components/starlight/Head.astro b/docs/src/components/starlight/Head.astro
index 957280dc..1b1d84bf 100644
--- a/docs/src/components/starlight/Head.astro
+++ b/docs/src/components/starlight/Head.astro
@@ -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 `
`, ``,
+ * `og:title`, `og:type`, `og:description`, `og:url`, `og:locale`, `og:site_name`,
+ * `twitter:card`, `twitter:site`, and ``. 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;
---
@@ -24,12 +109,43 @@ const ogImage = `${config.site.base_url}${config.metadata.meta_image}`;
+
-
-
+
-
-
-
+
+{
+ isConfiguredBanner && (
+ <>
+
+
+
+
+ >
+ )
+}
+
+{
+ isConfiguredBanner && (
+
+ )
+}
+
+{
+ breadcrumbSchema && (
+
+ )
+}
diff --git a/docs/src/layouts/components/SEO.astro b/docs/src/layouts/components/SEO.astro
index 2ef15c0e..840c3409 100644
--- a/docs/src/layouts/components/SEO.astro
+++ b/docs/src/layouts/components/SEO.astro
@@ -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;
@@ -26,6 +28,7 @@ const {
title,
description,
image,
+ imageAlt,
article,
canonical,
robots,
@@ -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 = "";
@@ -90,6 +116,20 @@ const ogType = article ? "article" : "website";
+
+
+{
+ isConfiguredBanner && (
+ <>
+
+
+
+ >
+ )
+}
{
@@ -110,10 +150,11 @@ const ogType = article ? "article" : "website";
}
-
-
-
-
-
+
+
+
+
+
+
diff --git a/docs/src/layouts/components/StructuredData.astro b/docs/src/layouts/components/StructuredData.astro
index 6af253c6..0e1acd9b 100644
--- a/docs/src/layouts/components/StructuredData.astro
+++ b/docs/src/layouts/components/StructuredData.astro
@@ -1,5 +1,11 @@
---
import config from "@/config/config.json";
+import { normalizeCanonicalUrl, toAbsoluteSecureUrl } from "@/lib/utils/seo";
+
+// Canonical root URL respecting the site's `trailing_slash` policy. Used as
+// the `url` for Organization/WebSite nodes and the "Home" breadcrumb item so
+// structured data matches the canonical form emitted in meta tags.
+const SITE_ROOT_URL = normalizeCanonicalUrl("/");
type ArticleProps = {
type: "article";
@@ -68,6 +74,7 @@ interface ImageObject extends SchemaBase {
interface PersonSchema extends SchemaBase {
"@type": "Person";
+ "@id": string;
name: string;
url: string;
image?: string;
@@ -76,19 +83,22 @@ interface PersonSchema extends SchemaBase {
interface OrganizationSchema extends SchemaBase {
"@type": "Organization";
+ "@id": string;
name: string;
url: string;
logo?: string;
sameAs: string[];
}
+type SchemaRef = { "@id": string };
+
interface ArticleSchema extends SchemaBase {
"@type": "Article";
headline: string;
datePublished: string;
dateModified: string;
- author: PersonSchema;
- publisher: OrganizationSchema;
+ author: PersonSchema | SchemaRef;
+ publisher: OrganizationSchema | SchemaRef;
mainEntityOfPage: {
"@type": "WebPage";
"@id": string;
@@ -101,8 +111,8 @@ interface WebPageSchema extends SchemaBase {
"@type": "WebPage";
name: string;
url: string;
- datePublished: string;
- dateModified: string;
+ datePublished?: string;
+ dateModified?: string;
breadcrumb: {
"@id": string;
};
@@ -123,9 +133,11 @@ interface BreadcrumbListSchema extends SchemaBase {
interface WebSiteSchema extends SchemaBase {
"@type": "WebSite";
+ "@id": string;
name: string;
url: string;
description: string;
+ publisher?: SchemaRef;
}
interface SoftwareApplicationSchema extends SchemaBase {
@@ -134,7 +146,7 @@ interface SoftwareApplicationSchema extends SchemaBase {
applicationCategory: string;
operatingSystem: string;
description?: string;
- owner: OrganizationSchema;
+ owner: OrganizationSchema | SchemaRef;
offers?: OfferSchema | OfferSchema[];
image?: string;
}
@@ -159,13 +171,23 @@ interface OfferSchema extends SchemaBase {
const { type } = Astro.props;
+// Stable identifiers for the canonical entity nodes. Using URL fragments keeps
+// them resolvable and lets us reference the same entity from multiple schemas
+// without duplicating fields.
+const ORGANIZATION_ID = `${config.site.base_url}/#organization`;
+const AUTHOR_ID = `${config.site.base_url}/#person-twannl`;
+const WEBSITE_ID = `${config.site.base_url}/#website`;
+
// Author data (hardcoded - single author blog)
const authorData: PersonSchema = {
"@context": "https://schema.org",
"@type": "Person",
+ "@id": AUTHOR_ID,
name: "Antoine van der Lee",
url: "https://www.avanderlee.com/",
- image: `${config.site.base_url}/images/author-image-antoine.jpg`,
+ image:
+ toAbsoluteSecureUrl("/images/author-image-antoine.jpg") ??
+ `${config.site.base_url}/images/author-image-antoine.jpg`,
sameAs: [
"https://x.com/twannl",
"https://www.linkedin.com/in/ajvanderlee",
@@ -177,9 +199,12 @@ const authorData: PersonSchema = {
const organizationData: OrganizationSchema = {
"@context": "https://schema.org",
"@type": "Organization",
+ "@id": ORGANIZATION_ID,
name: "RocketSim",
- url: config.site.base_url,
- logo: `${config.site.base_url}/images/rocketsim-app-icon.png`,
+ url: SITE_ROOT_URL,
+ logo:
+ toAbsoluteSecureUrl("/images/rocketsim-app-icon.png") ??
+ `${config.site.base_url}/images/rocketsim-app-icon.png`,
sameAs: [
"https://x.com/rocketsim_app",
"https://www.youtube.com/@rocketsimapp",
@@ -189,31 +214,41 @@ const organizationData: OrganizationSchema = {
// Build schemas based on type
const schemas: SchemaBase[] = [];
+// Add Organization globally for all page types (one lightweight, canonical node).
+schemas.push(organizationData);
+
if (type === "article") {
const { article } = Astro.props;
- // 1. Article schema
+ const articleUrl = normalizeCanonicalUrl(article.url);
+ const articleImageUrl = toAbsoluteSecureUrl(article.image?.url);
+
+ // Author is emitted as a top-level node and referenced by @id below.
+ schemas.push(authorData);
+
+ // 1. Article schema (references Organization/Person via @id, no inline copies)
const articleSchema: ArticleSchema = {
"@context": "https://schema.org",
"@type": "Article",
headline: article.headline,
datePublished: article.datePublished,
dateModified: article.dateModified,
- author: authorData,
- publisher: organizationData,
+ author: { "@id": AUTHOR_ID },
+ publisher: { "@id": ORGANIZATION_ID },
mainEntityOfPage: {
"@type": "WebPage",
- "@id": article.url,
+ "@id": articleUrl,
},
...(article.wordCount && { wordCount: article.wordCount }),
- ...(article.image && {
- image: {
- "@context": "https://schema.org",
- "@type": "ImageObject",
- url: article.image.url,
- ...(article.image.width && { width: article.image.width }),
- ...(article.image.height && { height: article.image.height }),
- } as ImageObject,
- }),
+ ...(article.image &&
+ articleImageUrl && {
+ image: {
+ "@context": "https://schema.org",
+ "@type": "ImageObject",
+ url: articleImageUrl,
+ ...(article.image.width && { width: article.image.width }),
+ ...(article.image.height && { height: article.image.height }),
+ } as ImageObject,
+ }),
};
schemas.push(articleSchema);
@@ -223,24 +258,25 @@ if (type === "article") {
"@context": "https://schema.org",
"@type": "WebPage",
name: article.headline,
- url: article.url,
+ url: articleUrl,
datePublished: article.datePublished,
dateModified: article.dateModified,
breadcrumb: {
- "@id": `${article.url}#breadcrumb`,
+ "@id": `${articleUrl}#breadcrumb`,
},
- ...(article.image && {
- primaryImageOfPage: {
- "@context": "https://schema.org",
- "@type": "ImageObject",
- url: article.image.url,
- contentUrl: article.image.url,
- ...(article.image.width && { width: article.image.width }),
- ...(article.image.height && { height: article.image.height }),
- ...(article.image.caption && { caption: article.image.caption }),
- } as ImageObject,
- thumbnailUrl: article.image.url,
- }),
+ ...(article.image &&
+ articleImageUrl && {
+ primaryImageOfPage: {
+ "@context": "https://schema.org",
+ "@type": "ImageObject",
+ url: articleImageUrl,
+ contentUrl: articleImageUrl,
+ ...(article.image.width && { width: article.image.width }),
+ ...(article.image.height && { height: article.image.height }),
+ ...(article.image.caption && { caption: article.image.caption }),
+ } as ImageObject,
+ thumbnailUrl: articleImageUrl,
+ }),
};
schemas.push(webPageSchema);
@@ -249,51 +285,48 @@ if (type === "article") {
const breadcrumbSchema: BreadcrumbListSchema = {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
- "@id": `${article.url}#breadcrumb`,
+ "@id": `${articleUrl}#breadcrumb`,
itemListElement: [
{
"@type": "ListItem",
position: 1,
name: "Home",
- item: config.site.base_url,
+ item: SITE_ROOT_URL,
},
{
"@type": "ListItem",
position: 2,
name: article.headline,
- item: article.url,
+ item: articleUrl,
},
],
};
schemas.push(breadcrumbSchema);
-
- // 4. Person schema (author)
- schemas.push(authorData);
}
if (type === "static") {
- // Static schemas for WebSite and Organization (used on homepage or base layout)
-
- // WebSite schema
+ // Static schemas for WebSite (Organization is emitted globally above).
const webSiteSchema: WebSiteSchema = {
"@context": "https://schema.org",
"@type": "WebSite",
+ "@id": WEBSITE_ID,
name: "RocketSim",
- url: config.site.base_url,
+ url: SITE_ROOT_URL,
description:
"RocketSim is the essential developer tool for building better apps faster on iOS, macOS, and visionOS.",
+ publisher: { "@id": ORGANIZATION_ID },
};
schemas.push(webSiteSchema);
-
- // Organization schema
- schemas.push(organizationData);
}
if (type === "product") {
const { product } = Astro.props;
- // 1. SoftwareApplication schema
+ const productUrl = normalizeCanonicalUrl(product.url);
+ const productImageUrl = toAbsoluteSecureUrl(product.image);
+
+ // 1. SoftwareApplication schema (references Organization via @id).
const softwareSchema: SoftwareApplicationSchema = {
"@context": "https://schema.org",
"@type": "SoftwareApplication",
@@ -301,8 +334,8 @@ if (type === "product") {
applicationCategory: "DeveloperApplication",
operatingSystem: "macOS",
...(product.description && { description: product.description }),
- owner: organizationData,
- ...(product.image && { image: product.image }),
+ owner: { "@id": ORGANIZATION_ID },
+ ...(productImageUrl && { image: productImageUrl }),
};
schemas.push(softwareSchema);
@@ -311,10 +344,8 @@ if (type === "product") {
"@context": "https://schema.org",
"@type": "WebPage",
name: product.name,
- url: product.url,
- datePublished: new Date().toISOString(),
- dateModified: new Date().toISOString(),
- breadcrumb: { "@id": `${product.url}#breadcrumb` },
+ url: productUrl,
+ breadcrumb: { "@id": `${productUrl}#breadcrumb` },
};
schemas.push(webPageSchema);
@@ -322,19 +353,19 @@ if (type === "product") {
const breadcrumbSchema: BreadcrumbListSchema = {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
- "@id": `${product.url}#breadcrumb`,
+ "@id": `${productUrl}#breadcrumb`,
itemListElement: [
{
"@type": "ListItem",
position: 1,
name: "Home",
- item: config.site.base_url,
+ item: SITE_ROOT_URL,
},
{
"@type": "ListItem",
position: 2,
name: product.name,
- item: product.url,
+ item: productUrl,
},
],
};
@@ -343,6 +374,7 @@ if (type === "product") {
if (type === "offer") {
const { offers } = Astro.props;
+ const offerUrl = normalizeCanonicalUrl(offers.url);
// 1. SoftwareApplication with individual Offer schemas for each tier
const pricingOffers: OfferSchema[] = [
{
@@ -392,8 +424,10 @@ if (type === "offer") {
applicationCategory: "DeveloperApplication",
operatingSystem: "macOS",
...(offers.description && { description: offers.description }),
- owner: organizationData,
- image: `${config.site.base_url}/images/rocketsim-app-icon.png`,
+ owner: { "@id": ORGANIZATION_ID },
+ image:
+ toAbsoluteSecureUrl("/images/rocketsim-app-icon.png") ??
+ `${config.site.base_url}/images/rocketsim-app-icon.png`,
offers: pricingOffers,
};
schemas.push(softwareWithOffersSchema);
@@ -403,10 +437,8 @@ if (type === "offer") {
"@context": "https://schema.org",
"@type": "WebPage",
name: offers.title,
- url: offers.url,
- datePublished: new Date().toISOString(),
- dateModified: new Date().toISOString(),
- breadcrumb: { "@id": `${offers.url}#breadcrumb` },
+ url: offerUrl,
+ breadcrumb: { "@id": `${offerUrl}#breadcrumb` },
};
schemas.push(webPageSchema);
@@ -414,32 +446,33 @@ if (type === "offer") {
const breadcrumbSchema: BreadcrumbListSchema = {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
- "@id": `${offers.url}#breadcrumb`,
+ "@id": `${offerUrl}#breadcrumb`,
itemListElement: [
{
"@type": "ListItem",
position: 1,
name: "Home",
- item: config.site.base_url,
+ item: SITE_ROOT_URL,
},
{
"@type": "ListItem",
position: 2,
name: offers.title,
- item: offers.url,
+ item: offerUrl,
},
],
};
schemas.push(breadcrumbSchema);
}
+
+const structuredDataGraph = {
+ "@context": "https://schema.org",
+ "@graph": schemas.map(({ "@context": _schemaContext, ...schema }) => schema),
+};
---
-{
- schemas.map((schema) => (
-
- ))
-}
+
diff --git a/docs/src/lib/utils/seo.ts b/docs/src/lib/utils/seo.ts
new file mode 100644
index 00000000..6b049ad3
--- /dev/null
+++ b/docs/src/lib/utils/seo.ts
@@ -0,0 +1,114 @@
+import config from "@/config/config.json";
+
+const SAFE_SCHEMES = new Set(["http:", "https:"]);
+
+/**
+ * Resolve a possibly-relative URL into an absolute one using `config.site.base_url`
+ * as the base. Returns `undefined` for empty, invalid, or unsafe-scheme inputs.
+ *
+ * Unsafe schemes (e.g. `javascript:`, `data:`, `mailto:`, `tel:`) are rejected so
+ * we never accidentally surface them through SEO meta tags or JSON-LD.
+ *
+ * Kept internal because all current callers want either the HTTPS-upgraded
+ * variant (`toAbsoluteSecureUrl`) or the canonical-normalized variant
+ * (`normalizeCanonicalUrl`).
+ */
+function toAbsoluteUrl(url?: string | null): string | undefined {
+ if (!url) return undefined;
+ try {
+ const resolved = new URL(url, config.site.base_url);
+ if (!SAFE_SCHEMES.has(resolved.protocol)) return undefined;
+ return resolved.toString();
+ } catch {
+ return undefined;
+ }
+}
+
+/**
+ * Same as `toAbsoluteUrl` but upgrades `http://` to `https://` so OG/Twitter
+ * crawlers (which prefer secure assets) never receive an insecure URL.
+ */
+export function toAbsoluteSecureUrl(url?: string | null): string | undefined {
+ const resolved = toAbsoluteUrl(url);
+ if (!resolved) return undefined;
+ return resolved.startsWith("http://")
+ ? `https://${resolved.slice("http://".length)}`
+ : resolved;
+}
+
+/**
+ * Normalize a canonical URL: resolve relatives against the site origin and
+ * append a trailing slash to the path (preserving any query string and
+ * fragment) when the site is configured for it. Falls back to the site
+ * origin when no URL is provided.
+ */
+export function normalizeCanonicalUrl(url?: string | null): string {
+ const resolved = toAbsoluteUrl(url) ?? config.site.base_url;
+ if (!config.site.trailing_slash) return resolved;
+ try {
+ const parsed = new URL(resolved);
+ if (!parsed.pathname.endsWith("/")) {
+ parsed.pathname = `${parsed.pathname}/`;
+ }
+ return parsed.toString();
+ } catch {
+ return resolved.endsWith("/") ? resolved : `${resolved}/`;
+ }
+}
+
+/**
+ * Build an absolute URL from a sequence of path segments, applying trailing
+ * slash policy. Empty segments are ignored.
+ *
+ * @example
+ * pathToAbsoluteUrl(["docs", "getting-started"])
+ * // => "https://www.rocketsim.app/docs/getting-started/"
+ *
+ * @public Consumed by the Starlight `Head.astro` override; knip's astro plugin
+ * marks Starlight component overrides as `ignore exports`, which currently
+ * prevents its import statements from counting toward upstream usage.
+ */
+export function pathToAbsoluteUrl(segments: string[]): string {
+ const path = segments.filter(Boolean).join("/");
+ return normalizeCanonicalUrl(path ? `/${path}` : "/");
+}
+
+/**
+ * Convert a kebab-case URL slug into a human-readable, title-cased label
+ * suitable for breadcrumb display (e.g. "getting-started" → "Getting Started").
+ *
+ * @public Consumed by the Starlight `Head.astro` override; see
+ * `pathToAbsoluteUrl` for why this needs to be explicitly marked public.
+ */
+export function slugToTitle(slug: string): string {
+ const brandWords: Record = {
+ rocketsim: "RocketSim",
+ ios: "iOS",
+ macos: "macOS",
+ };
+
+ return slug
+ .split(/[-_]/g)
+ .filter(Boolean)
+ .map(
+ (word) =>
+ brandWords[word.toLowerCase()] ??
+ word.charAt(0).toUpperCase() + word.slice(1),
+ )
+ .join(" ");
+}
+
+/**
+ * Safe wrapper around `decodeURIComponent` that returns the raw input when
+ * decoding fails (e.g. on malformed `%`-sequences).
+ *
+ * @public Consumed by the Starlight `Head.astro` override; see
+ * `pathToAbsoluteUrl` for why this needs to be explicitly marked public.
+ */
+export function safeDecodePathSegment(segment: string): string {
+ try {
+ return decodeURIComponent(segment);
+ } catch {
+ return segment;
+ }
+}