From 8dca3f9df5e13eedb0e59bccfaca1e7b53902d67 Mon Sep 17 00:00:00 2001
From: Antoine van der Lee <4329185+AvdLee@users.noreply.github.com>
Date: Sun, 24 May 2026 13:39:36 +0200
Subject: [PATCH 1/8] Improve site-wide SEO metadata and JSON-LD defaults
---
docs/src/components/starlight/Head.astro | 37 ++++++++++++++++++-
docs/src/layouts/components/SEO.astro | 19 +++++-----
.../layouts/components/StructuredData.astro | 14 +++----
docs/src/lib/utils/seo.ts | 18 +++++++++
4 files changed, 68 insertions(+), 20 deletions(-)
create mode 100644 docs/src/lib/utils/seo.ts
diff --git a/docs/src/components/starlight/Head.astro b/docs/src/components/starlight/Head.astro
index 957280dc..7c9c71bd 100644
--- a/docs/src/components/starlight/Head.astro
+++ b/docs/src/components/starlight/Head.astro
@@ -6,8 +6,25 @@
import Default from "@astrojs/starlight/components/Head.astro";
import PlausibleAnalytics from "@/components/PlausibleAnalytics.astro";
import config from "@/config/config.json";
+import { normalizeCanonicalUrl, toAbsoluteUrl } from "@/lib/utils/seo";
-const ogImage = `${config.site.base_url}${config.metadata.meta_image}`;
+const ogImage =
+ toAbsoluteUrl(config.metadata.meta_image) ||
+ `${config.site.base_url}/images/rocketsim-app-icon.png`;
+const canonicalUrl = normalizeCanonicalUrl(Astro.url.href);
+const pathParts = Astro.url.pathname
+ .split("/")
+ .filter(Boolean)
+ .map((segment) => decodeURIComponent(segment));
+const breadcrumbItems = pathParts.map((segment, index) => {
+ const href = `${config.site.base_url}/${pathParts.slice(0, index + 1).join("/")}/`;
+ return {
+ "@type": "ListItem",
+ position: index + 1,
+ name: segment.replace(/-/g, " "),
+ item: href,
+ };
+});
---
@@ -27,9 +44,25 @@ const ogImage = `${config.site.base_url}${config.metadata.meta_image}`;
+
-
+
+
+
+{
+ Astro.url.pathname.startsWith("/docs") && breadcrumbItems.length > 0 && (
+
+ )
+}
diff --git a/docs/src/layouts/components/SEO.astro b/docs/src/layouts/components/SEO.astro
index 2ef15c0e..4f77ac79 100644
--- a/docs/src/layouts/components/SEO.astro
+++ b/docs/src/layouts/components/SEO.astro
@@ -1,5 +1,6 @@
---
import config from "@/config/config.json";
+import { normalizeCanonicalUrl, toAbsoluteUrl } from "@/lib/utils/seo";
export interface Props {
title: string;
@@ -35,16 +36,16 @@ 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}`;
+const ogImage =
+ toAbsoluteUrl(image) ||
+ toAbsoluteUrl(config.metadata.meta_image) ||
+ `${config.site.base_url}/images/rocketsim-app-icon.png`;
// Build robots meta content
let robotsContent = "";
@@ -111,9 +112,9 @@ const ogType = article ? "article" : "website";
-
-
-
-
+
+
+
+
diff --git a/docs/src/layouts/components/StructuredData.astro b/docs/src/layouts/components/StructuredData.astro
index 6af253c6..44ed6f50 100644
--- a/docs/src/layouts/components/StructuredData.astro
+++ b/docs/src/layouts/components/StructuredData.astro
@@ -101,8 +101,8 @@ interface WebPageSchema extends SchemaBase {
"@type": "WebPage";
name: string;
url: string;
- datePublished: string;
- dateModified: string;
+ datePublished?: string;
+ dateModified?: string;
breadcrumb: {
"@id": string;
};
@@ -189,6 +189,9 @@ const organizationData: OrganizationSchema = {
// Build schemas based on type
const schemas: SchemaBase[] = [];
+// Add Organization globally for all page types (one lightweight schema node).
+schemas.push(organizationData);
+
if (type === "article") {
const { article } = Astro.props;
// 1. Article schema
@@ -286,9 +289,6 @@ if (type === "static") {
};
schemas.push(webSiteSchema);
-
- // Organization schema
- schemas.push(organizationData);
}
if (type === "product") {
@@ -312,8 +312,6 @@ if (type === "product") {
"@type": "WebPage",
name: product.name,
url: product.url,
- datePublished: new Date().toISOString(),
- dateModified: new Date().toISOString(),
breadcrumb: { "@id": `${product.url}#breadcrumb` },
};
schemas.push(webPageSchema);
@@ -404,8 +402,6 @@ if (type === "offer") {
"@type": "WebPage",
name: offers.title,
url: offers.url,
- datePublished: new Date().toISOString(),
- dateModified: new Date().toISOString(),
breadcrumb: { "@id": `${offers.url}#breadcrumb` },
};
schemas.push(webPageSchema);
diff --git a/docs/src/lib/utils/seo.ts b/docs/src/lib/utils/seo.ts
new file mode 100644
index 00000000..b7a8305a
--- /dev/null
+++ b/docs/src/lib/utils/seo.ts
@@ -0,0 +1,18 @@
+import config from "@/config/config.json";
+
+export function toAbsoluteUrl(url?: string): string | undefined {
+ if (!url) return undefined;
+ try {
+ return new URL(url, config.site.base_url).toString();
+ } catch {
+ return undefined;
+ }
+}
+
+export function normalizeCanonicalUrl(url?: string): string {
+ const resolved = toAbsoluteUrl(url) ?? config.site.base_url;
+ if (config.site.trailing_slash && !resolved.endsWith("/")) {
+ return `${resolved}/`;
+ }
+ return resolved;
+}
From 2bc025dba34c93c517c03604699b1cd84f86375b Mon Sep 17 00:00:00 2001
From: Antoine van der Lee <4329185+AvdLee@users.noreply.github.com>
Date: Tue, 26 May 2026 11:38:12 +0200
Subject: [PATCH 2/8] Refine SEO metadata: @id linking, dedup, image
dimensions, breadcrumb polish
- Harden seo.ts with safe scheme allow-list, https upgrade helper,
pathToAbsoluteUrl, slugToTitle, and safeDecodePathSegment helpers.
- Use stable @id references for Organization, Person, and WebSite in
StructuredData.astro so Article.publisher/author no longer inline
duplicate entity data; route logo/author/article images through
toAbsoluteSecureUrl.
- Drop duplicate canonical and og:url from the Starlight Head override
(Starlight already emits them); fix the docs BreadcrumbList to include
a Home root item, an @id, title-cased segment names, safe path decoding,
and a strict /docs/ scope check.
- Bring docs Head to parity with Base.astro (apple-mobile-web-app-title)
and add og:image:secure_url, og:image:width/height/type, og:image:alt,
and twitter:image:alt for both pipelines.
- Switch twitter:card in SEO.astro to name= for full Twitter spec
consistency with the rest of the meta tags.
---
docs/src/components/starlight/Head.astro | 112 ++++++++++++++----
docs/src/layouts/components/SEO.astro | 47 +++++++-
.../layouts/components/StructuredData.astro | 110 ++++++++++-------
docs/src/lib/utils/seo.ts | 71 ++++++++++-
4 files changed, 264 insertions(+), 76 deletions(-)
diff --git a/docs/src/components/starlight/Head.astro b/docs/src/components/starlight/Head.astro
index 7c9c71bd..62812e96 100644
--- a/docs/src/components/starlight/Head.astro
+++ b/docs/src/components/starlight/Head.astro
@@ -1,30 +1,76 @@
---
/**
* 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:description`, `og:url`, `og:locale`, `og:site_name`,
+ * `twitter:card`, `twitter:site`, and ``. We only add tags
+ * Starlight does not provide (image-related OG/Twitter tags) and structured data.
*/
import Default from "@astrojs/starlight/components/Head.astro";
import PlausibleAnalytics from "@/components/PlausibleAnalytics.astro";
import config from "@/config/config.json";
-import { normalizeCanonicalUrl, toAbsoluteUrl } from "@/lib/utils/seo";
+import {
+ normalizeCanonicalUrl,
+ pathToAbsoluteUrl,
+ safeDecodePathSegment,
+ slugToTitle,
+ toAbsoluteSecureUrl,
+} from "@/lib/utils/seo";
+const DEFAULT_OG_IMAGE_FALLBACK = "/images/rocketsim-app-icon.png";
+// Dimensions of `public/og-banner-rocketsim.jpg`. Kept in sync with that asset.
+const DEFAULT_OG_IMAGE_WIDTH = 1600;
+const DEFAULT_OG_IMAGE_HEIGHT = 840;
+const DEFAULT_OG_IMAGE_ALT = "RocketSim — build apps faster";
+
+const configuredOgImage = toAbsoluteSecureUrl(config.metadata.meta_image);
const ogImage =
- toAbsoluteUrl(config.metadata.meta_image) ||
- `${config.site.base_url}/images/rocketsim-app-icon.png`;
+ configuredOgImage ??
+ toAbsoluteSecureUrl(DEFAULT_OG_IMAGE_FALLBACK) ??
+ `${config.site.base_url}${DEFAULT_OG_IMAGE_FALLBACK}`;
+const isDefaultOgImage = ogImage === configuredOgImage;
+
const canonicalUrl = normalizeCanonicalUrl(Astro.url.href);
-const pathParts = Astro.url.pathname
+
+const pathSegments = Astro.url.pathname
.split("/")
.filter(Boolean)
- .map((segment) => decodeURIComponent(segment));
-const breadcrumbItems = pathParts.map((segment, index) => {
- const href = `${config.site.base_url}/${pathParts.slice(0, index + 1).join("/")}/`;
- return {
- "@type": "ListItem",
- position: index + 1,
- name: segment.replace(/-/g, " "),
- item: href,
- };
-});
+ .map(safeDecodePathSegment);
+
+// 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("/"),
+ },
+ ...pathSegments.map((segment, index) => ({
+ "@type": "ListItem" as const,
+ position: index + 2,
+ name: slugToTitle(segment),
+ item: pathToAbsoluteUrl(pathSegments.slice(0, index + 1)),
+ })),
+ ]
+ : [];
+
+const breadcrumbSchema =
+ breadcrumbItems.length > 1
+ ? {
+ "@context": "https://schema.org",
+ "@type": "BreadcrumbList",
+ "@id": `${canonicalUrl}#breadcrumb`,
+ itemListElement: breadcrumbItems,
+ }
+ : null;
---
@@ -41,26 +87,40 @@ const breadcrumbItems = pathParts.map((segment, index) => {
+
-
+
-
-
+
+{
+ isDefaultOgImage && (
+ <>
+
+
+
+ >
+ )
+}
+
-
-
+
{
- Astro.url.pathname.startsWith("/docs") && breadcrumbItems.length > 0 && (
+ breadcrumbSchema && (
)
}
diff --git a/docs/src/layouts/components/SEO.astro b/docs/src/layouts/components/SEO.astro
index 4f77ac79..356a9e0b 100644
--- a/docs/src/layouts/components/SEO.astro
+++ b/docs/src/layouts/components/SEO.astro
@@ -1,11 +1,12 @@
---
import config from "@/config/config.json";
-import { normalizeCanonicalUrl, toAbsoluteUrl } from "@/lib/utils/seo";
+import { normalizeCanonicalUrl, toAbsoluteSecureUrl } from "@/lib/utils/seo";
export interface Props {
title: string;
description?: string;
image?: string;
+ imageAlt?: string;
article?: {
publishedTime?: string;
modifiedTime?: string;
@@ -27,6 +28,7 @@ const {
title,
description,
image,
+ imageAlt,
article,
canonical,
robots,
@@ -41,11 +43,26 @@ 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
+// Default banner asset metadata. Kept in sync with `public/og-banner-rocketsim.jpg`.
+const DEFAULT_OG_IMAGE_FALLBACK = "/images/rocketsim-app-icon.png";
+const DEFAULT_OG_IMAGE_WIDTH = 1600;
+const DEFAULT_OG_IMAGE_HEIGHT = 840;
+const DEFAULT_OG_IMAGE_ALT = "RocketSim — build apps faster";
+
+// 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 =
- toAbsoluteUrl(image) ||
- toAbsoluteUrl(config.metadata.meta_image) ||
- `${config.site.base_url}/images/rocketsim-app-icon.png`;
+ providedImage ??
+ configuredImage ??
+ toAbsoluteSecureUrl(DEFAULT_OG_IMAGE_FALLBACK) ??
+ `${config.site.base_url}${DEFAULT_OG_IMAGE_FALLBACK}`;
+
+// Only emit width/height when we know them (i.e. we're serving the default banner).
+const isDefaultOgImage = !providedImage && ogImage === configuredImage;
+const ogImageAlt =
+ imageAlt ?? (isDefaultOgImage ? DEFAULT_OG_IMAGE_ALT : title);
// Build robots meta content
let robotsContent = "";
@@ -91,6 +108,23 @@ const ogType = article ? "article" : "website";
+
+
+{
+ isDefaultOgImage && (
+ <>
+
+
+
+ >
+ )
+}
{
@@ -111,10 +145,11 @@ const ogType = article ? "article" : "website";
}
-
+
+
diff --git a/docs/src/layouts/components/StructuredData.astro b/docs/src/layouts/components/StructuredData.astro
index 44ed6f50..0af09fd7 100644
--- a/docs/src/layouts/components/StructuredData.astro
+++ b/docs/src/layouts/components/StructuredData.astro
@@ -1,5 +1,6 @@
---
import config from "@/config/config.json";
+import { toAbsoluteSecureUrl } from "@/lib/utils/seo";
type ArticleProps = {
type: "article";
@@ -68,6 +69,7 @@ interface ImageObject extends SchemaBase {
interface PersonSchema extends SchemaBase {
"@type": "Person";
+ "@id": string;
name: string;
url: string;
image?: string;
@@ -76,19 +78,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;
@@ -123,9 +128,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 +141,7 @@ interface SoftwareApplicationSchema extends SchemaBase {
applicationCategory: string;
operatingSystem: string;
description?: string;
- owner: OrganizationSchema;
+ owner: OrganizationSchema | SchemaRef;
offers?: OfferSchema | OfferSchema[];
image?: string;
}
@@ -159,13 +166,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 +194,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`,
+ 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,34 +209,40 @@ const organizationData: OrganizationSchema = {
// Build schemas based on type
const schemas: SchemaBase[] = [];
-// Add Organization globally for all page types (one lightweight schema node).
+// 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 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,
},
...(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);
@@ -232,18 +258,19 @@ if (type === "article") {
breadcrumb: {
"@id": `${article.url}#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);
@@ -270,22 +297,19 @@ if (type === "article") {
};
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,
description:
"RocketSim is the essential developer tool for building better apps faster on iOS, macOS, and visionOS.",
+ publisher: { "@id": ORGANIZATION_ID },
};
schemas.push(webSiteSchema);
@@ -293,7 +317,9 @@ if (type === "static") {
if (type === "product") {
const { product } = Astro.props;
- // 1. SoftwareApplication schema
+ const productImageUrl = toAbsoluteSecureUrl(product.image);
+
+ // 1. SoftwareApplication schema (references Organization via @id).
const softwareSchema: SoftwareApplicationSchema = {
"@context": "https://schema.org",
"@type": "SoftwareApplication",
@@ -301,8 +327,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);
@@ -390,8 +416,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);
diff --git a/docs/src/lib/utils/seo.ts b/docs/src/lib/utils/seo.ts
index b7a8305a..6ae9ffd9 100644
--- a/docs/src/lib/utils/seo.ts
+++ b/docs/src/lib/utils/seo.ts
@@ -1,18 +1,83 @@
import config from "@/config/config.json";
-export function toAbsoluteUrl(url?: string): string | undefined {
+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.
+ */
+export function toAbsoluteUrl(url?: string | null): string | undefined {
if (!url) return undefined;
try {
- return new URL(url, config.site.base_url).toString();
+ const resolved = new URL(url, config.site.base_url);
+ if (!SAFE_SCHEMES.has(resolved.protocol)) return undefined;
+ return resolved.toString();
} catch {
return undefined;
}
}
-export function normalizeCanonicalUrl(url?: string): string {
+/**
+ * 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 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 && !resolved.endsWith("/")) {
return `${resolved}/`;
}
return 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/"
+ */
+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").
+ */
+export function slugToTitle(slug: string): string {
+ return slug
+ .split(/[-_]/g)
+ .filter(Boolean)
+ .map((word) => 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).
+ */
+export function safeDecodePathSegment(segment: string): string {
+ try {
+ return decodeURIComponent(segment);
+ } catch {
+ return segment;
+ }
+}
From 9bdada90c81d4a8f813b098b85457c3c978c4f4e Mon Sep 17 00:00:00 2001
From: Antoine van der Lee <4329185+AvdLee@users.noreply.github.com>
Date: Tue, 26 May 2026 11:45:16 +0200
Subject: [PATCH 3/8] Address Copilot review feedback
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- normalizeCanonicalUrl now appends the trailing slash to the URL pathname
instead of the full string, preserving any query string and fragment
(e.g. "/page?q=1" → "/page/?q=1" rather than the invalid "/page?q=1/").
- Build BreadcrumbList hrefs from the raw (still-encoded) path segments so
percent-encoded characters such as "%20" stay valid in the URL, while only
the decoded form is used for the human-readable name.
---
docs/src/components/starlight/Head.astro | 15 ++++++++-------
docs/src/lib/utils/seo.ts | 17 ++++++++++++-----
2 files changed, 20 insertions(+), 12 deletions(-)
diff --git a/docs/src/components/starlight/Head.astro b/docs/src/components/starlight/Head.astro
index 62812e96..1bac60da 100644
--- a/docs/src/components/starlight/Head.astro
+++ b/docs/src/components/starlight/Head.astro
@@ -35,10 +35,11 @@ const isDefaultOgImage = ogImage === configuredOgImage;
const canonicalUrl = normalizeCanonicalUrl(Astro.url.href);
-const pathSegments = Astro.url.pathname
- .split("/")
- .filter(Boolean)
- .map(safeDecodePathSegment);
+// 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);
// BreadcrumbList is scoped to docs routes only. Use a strict "/docs/" match so
// hypothetical paths like "/docs-something/" never trigger it.
@@ -53,11 +54,11 @@ const breadcrumbItems = isDocsRoute
name: "Home",
item: normalizeCanonicalUrl("/"),
},
- ...pathSegments.map((segment, index) => ({
+ ...rawPathSegments.map((segment, index) => ({
"@type": "ListItem" as const,
position: index + 2,
- name: slugToTitle(segment),
- item: pathToAbsoluteUrl(pathSegments.slice(0, index + 1)),
+ name: slugToTitle(decodedPathSegments[index] ?? segment),
+ item: pathToAbsoluteUrl(rawPathSegments.slice(0, index + 1)),
})),
]
: [];
diff --git a/docs/src/lib/utils/seo.ts b/docs/src/lib/utils/seo.ts
index 6ae9ffd9..3e104916 100644
--- a/docs/src/lib/utils/seo.ts
+++ b/docs/src/lib/utils/seo.ts
@@ -34,15 +34,22 @@ export function toAbsoluteSecureUrl(url?: string | null): string | undefined {
/**
* Normalize a canonical URL: resolve relatives against the site origin and
- * append a trailing slash when the site is configured for it. Falls back to
- * the site origin when no URL is provided.
+ * 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 && !resolved.endsWith("/")) {
- return `${resolved}/`;
+ 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}/`;
}
- return resolved;
}
/**
From 79e906b675481e0444df3969ee0772f963f5037a Mon Sep 17 00:00:00 2001
From: Antoine van der Lee <4329185+AvdLee@users.noreply.github.com>
Date: Tue, 26 May 2026 11:50:18 +0200
Subject: [PATCH 4/8] Address Copilot round-2 feedback
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Rename DEFAULT_OG_IMAGE_* constants to DEFAULT_BANNER_* and split out
APP_ICON_FALLBACK_PATH so the dimension/alt constants no longer share a
name with an unrelated fallback path. Renamed isDefaultOgImage →
isConfiguredBanner to mirror.
- Move the og:site_name override into Starlight's head config in
astro.config.ts. Starlight dedupes head entries by property/name, so the
override now cleanly replaces Starlight's default "RocketSim Docs" with
"RocketSim" instead of double-emitting both tags.
---
docs/astro.config.ts | 9 ++++
docs/src/components/starlight/Head.astro | 52 +++++++++++++-----------
docs/src/layouts/components/SEO.astro | 37 +++++++++--------
3 files changed, 58 insertions(+), 40 deletions(-)
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 1bac60da..5d7ae0a4 100644
--- a/docs/src/components/starlight/Head.astro
+++ b/docs/src/components/starlight/Head.astro
@@ -5,9 +5,12 @@
* BreadcrumbList JSON-LD for /docs/* routes.
*
* NOTE: Starlight's default Head already emits ``, ``,
- * `og:title`, `og:description`, `og:url`, `og:locale`, `og:site_name`,
- * `twitter:card`, `twitter:site`, and ``. We only add tags
- * Starlight does not provide (image-related OG/Twitter tags) and structured data.
+ * `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";
@@ -20,18 +23,22 @@ import {
toAbsoluteSecureUrl,
} from "@/lib/utils/seo";
-const DEFAULT_OG_IMAGE_FALLBACK = "/images/rocketsim-app-icon.png";
-// Dimensions of `public/og-banner-rocketsim.jpg`. Kept in sync with that asset.
-const DEFAULT_OG_IMAGE_WIDTH = 1600;
-const DEFAULT_OG_IMAGE_HEIGHT = 840;
-const DEFAULT_OG_IMAGE_ALT = "RocketSim — build apps faster";
+// 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 configuredOgImage = toAbsoluteSecureUrl(config.metadata.meta_image);
+const configuredBanner = toAbsoluteSecureUrl(config.metadata.meta_image);
const ogImage =
- configuredOgImage ??
- toAbsoluteSecureUrl(DEFAULT_OG_IMAGE_FALLBACK) ??
- `${config.site.base_url}${DEFAULT_OG_IMAGE_FALLBACK}`;
-const isDefaultOgImage = ogImage === configuredOgImage;
+ 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);
@@ -91,30 +98,27 @@ const breadcrumbSchema =
-
{
- isDefaultOgImage && (
+ isConfiguredBanner && (
<>
-
+
>
)
}
-
+
-
+
{
breadcrumbSchema && (
diff --git a/docs/src/layouts/components/SEO.astro b/docs/src/layouts/components/SEO.astro
index 356a9e0b..840c3409 100644
--- a/docs/src/layouts/components/SEO.astro
+++ b/docs/src/layouts/components/SEO.astro
@@ -43,11 +43,14 @@ const canonicalUrl = normalizeCanonicalUrl(rawCanonicalUrl);
// Use provided description or fall back to default meta description
const metaDescription = description || config.metadata.meta_description;
-// Default banner asset metadata. Kept in sync with `public/og-banner-rocketsim.jpg`.
-const DEFAULT_OG_IMAGE_FALLBACK = "/images/rocketsim-app-icon.png";
-const DEFAULT_OG_IMAGE_WIDTH = 1600;
-const DEFAULT_OG_IMAGE_HEIGHT = 840;
-const DEFAULT_OG_IMAGE_ALT = "RocketSim — build apps faster";
+// 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.
@@ -56,13 +59,18 @@ const configuredImage = toAbsoluteSecureUrl(config.metadata.meta_image);
const ogImage =
providedImage ??
configuredImage ??
- toAbsoluteSecureUrl(DEFAULT_OG_IMAGE_FALLBACK) ??
- `${config.site.base_url}${DEFAULT_OG_IMAGE_FALLBACK}`;
+ toAbsoluteSecureUrl(APP_ICON_FALLBACK_PATH) ??
+ `${config.site.base_url}${APP_ICON_FALLBACK_PATH}`;
-// Only emit width/height when we know them (i.e. we're serving the default banner).
-const isDefaultOgImage = !providedImage && ogImage === configuredImage;
+// 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 ?? (isDefaultOgImage ? DEFAULT_OG_IMAGE_ALT : title);
+ imageAlt ?? (isConfiguredBanner ? DEFAULT_BANNER_ALT : title);
// Build robots meta content
let robotsContent = "";
@@ -111,15 +119,12 @@ const ogType = article ? "article" : "website";
{
- isDefaultOgImage && (
+ isConfiguredBanner && (
<>
-
+
>
From f6948732a31257522a6169598552dbb022ac1086 Mon Sep 17 00:00:00 2001
From: Antoine van der Lee <4329185+AvdLee@users.noreply.github.com>
Date: Tue, 26 May 2026 11:59:46 +0200
Subject: [PATCH 5/8] Fix knip unused-exports CI failure for seo.ts helpers
- Unexport `toAbsoluteUrl` since all callers go through the HTTPS-upgrading
`toAbsoluteSecureUrl` or the canonical-normalizing `normalizeCanonicalUrl`.
- Annotate `pathToAbsoluteUrl`, `slugToTitle`, and `safeDecodePathSegment`
with `@public`. knip's astro plugin auto-registers Starlight component
overrides (here, `Head.astro`) as entries in `ignore exports` mode, which
prevents their import statements from counting toward upstream usage,
causing false-positive "unused exports" reports for these helpers.
---
docs/src/lib/utils/seo.ts | 16 +++++++++++++++-
1 file changed, 15 insertions(+), 1 deletion(-)
diff --git a/docs/src/lib/utils/seo.ts b/docs/src/lib/utils/seo.ts
index 3e104916..669c71d6 100644
--- a/docs/src/lib/utils/seo.ts
+++ b/docs/src/lib/utils/seo.ts
@@ -8,8 +8,12 @@ const SAFE_SCHEMES = new Set(["http:", "https:"]);
*
* 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`).
*/
-export function toAbsoluteUrl(url?: string | null): string | undefined {
+function toAbsoluteUrl(url?: string | null): string | undefined {
if (!url) return undefined;
try {
const resolved = new URL(url, config.site.base_url);
@@ -59,6 +63,10 @@ export function normalizeCanonicalUrl(url?: string | null): string {
* @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("/");
@@ -68,6 +76,9 @@ export function pathToAbsoluteUrl(segments: string[]): string {
/**
* 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 {
return slug
@@ -80,6 +91,9 @@ export function slugToTitle(slug: string): string {
/**
* 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 {
From e76cd1378136baf045f0c1f0fee5705aa17984f5 Mon Sep 17 00:00:00 2001
From: Antoine van der Lee <4329185+AvdLee@users.noreply.github.com>
Date: Tue, 26 May 2026 12:06:04 +0200
Subject: [PATCH 6/8] Address Copilot round-3 feedback
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Only emit `og:image:alt`/`og:image:type`/`og:image:width`/`og:image:height`
and `twitter:image:alt` when the configured banner is the one actually
being served. Previously the alt text always claimed "RocketSim — build
apps faster", which was misleading when the app-icon fallback was used.
- Normalize all structured-data root URLs through `normalizeCanonicalUrl`
so Organization, WebSite, and "Home" breadcrumb items match the canonical
trailing-slash form (e.g. `https://www.rocketsim.app/` rather than the
bare base URL without a trailing slash).
- Extend the same normalization to per-page article/product/offer URLs so
WebPage.url, BreadcrumbList @id and item URLs stay consistent with the
canonical emitted in meta tags, regardless of what the page passes in.
---
docs/src/components/starlight/Head.astro | 8 +++-
.../layouts/components/StructuredData.astro | 46 +++++++++++--------
2 files changed, 33 insertions(+), 21 deletions(-)
diff --git a/docs/src/components/starlight/Head.astro b/docs/src/components/starlight/Head.astro
index 5d7ae0a4..e970b87a 100644
--- a/docs/src/components/starlight/Head.astro
+++ b/docs/src/components/starlight/Head.astro
@@ -113,12 +113,16 @@ const breadcrumbSchema =
content={String(DEFAULT_BANNER_HEIGHT)}
/>
+
>
)
}
-
-
+{
+ isConfiguredBanner && (
+
+ )
+}
{
breadcrumbSchema && (
diff --git a/docs/src/layouts/components/StructuredData.astro b/docs/src/layouts/components/StructuredData.astro
index 0af09fd7..efa4c4b5 100644
--- a/docs/src/layouts/components/StructuredData.astro
+++ b/docs/src/layouts/components/StructuredData.astro
@@ -1,6 +1,11 @@
---
import config from "@/config/config.json";
-import { toAbsoluteSecureUrl } from "@/lib/utils/seo";
+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";
@@ -196,7 +201,7 @@ const organizationData: OrganizationSchema = {
"@type": "Organization",
"@id": ORGANIZATION_ID,
name: "RocketSim",
- url: config.site.base_url,
+ url: SITE_ROOT_URL,
logo:
toAbsoluteSecureUrl("/images/rocketsim-app-icon.png") ??
`${config.site.base_url}/images/rocketsim-app-icon.png`,
@@ -214,6 +219,7 @@ schemas.push(organizationData);
if (type === "article") {
const { article } = Astro.props;
+ const articleUrl = normalizeCanonicalUrl(article.url);
const articleImageUrl = toAbsoluteSecureUrl(article.image?.url);
// Author is emitted as a top-level node and referenced by @id below.
@@ -230,7 +236,7 @@ if (type === "article") {
publisher: { "@id": ORGANIZATION_ID },
mainEntityOfPage: {
"@type": "WebPage",
- "@id": article.url,
+ "@id": articleUrl,
},
...(article.wordCount && { wordCount: article.wordCount }),
...(article.image &&
@@ -252,11 +258,11 @@ 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 &&
articleImageUrl && {
@@ -279,19 +285,19 @@ 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,
},
],
};
@@ -306,7 +312,7 @@ if (type === "static") {
"@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 },
@@ -317,6 +323,7 @@ if (type === "static") {
if (type === "product") {
const { product } = Astro.props;
+ const productUrl = normalizeCanonicalUrl(product.url);
const productImageUrl = toAbsoluteSecureUrl(product.image);
// 1. SoftwareApplication schema (references Organization via @id).
@@ -337,8 +344,8 @@ if (type === "product") {
"@context": "https://schema.org",
"@type": "WebPage",
name: product.name,
- url: product.url,
- breadcrumb: { "@id": `${product.url}#breadcrumb` },
+ url: productUrl,
+ breadcrumb: { "@id": `${productUrl}#breadcrumb` },
};
schemas.push(webPageSchema);
@@ -346,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,
},
],
};
@@ -367,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[] = [
{
@@ -429,8 +437,8 @@ if (type === "offer") {
"@context": "https://schema.org",
"@type": "WebPage",
name: offers.title,
- url: offers.url,
- breadcrumb: { "@id": `${offers.url}#breadcrumb` },
+ url: offerUrl,
+ breadcrumb: { "@id": `${offerUrl}#breadcrumb` },
};
schemas.push(webPageSchema);
@@ -438,19 +446,19 @@ 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,
},
],
};
From 7716cda92c052ea64ef1183635101797d0aeef01 Mon Sep 17 00:00:00 2001
From: Antoine van der Lee <4329185+AvdLee@users.noreply.github.com>
Date: Wed, 27 May 2026 13:40:51 +0200
Subject: [PATCH 7/8] Polish breadcrumb labels and JSON-LD output
Preserve RocketSim/iOS/macOS casing in slug-derived breadcrumb labels so docs routes render brand names correctly. Emit structured data as a single @graph JSON-LD block per page, keeping entity references together and simplifying validation.
---
.../layouts/components/StructuredData.astro | 19 ++++++++++---------
docs/src/lib/utils/seo.ts | 12 +++++++++++-
2 files changed, 21 insertions(+), 10 deletions(-)
diff --git a/docs/src/layouts/components/StructuredData.astro b/docs/src/layouts/components/StructuredData.astro
index efa4c4b5..0e1acd9b 100644
--- a/docs/src/layouts/components/StructuredData.astro
+++ b/docs/src/layouts/components/StructuredData.astro
@@ -464,14 +464,15 @@ if (type === "offer") {
};
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
index 669c71d6..6b049ad3 100644
--- a/docs/src/lib/utils/seo.ts
+++ b/docs/src/lib/utils/seo.ts
@@ -81,10 +81,20 @@ export function pathToAbsoluteUrl(segments: string[]): string {
* `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) => word.charAt(0).toUpperCase() + word.slice(1))
+ .map(
+ (word) =>
+ brandWords[word.toLowerCase()] ??
+ word.charAt(0).toUpperCase() + word.slice(1),
+ )
.join(" ");
}
From 545552d0c3d5fb3d7059c12b4dee13890614bf73 Mon Sep 17 00:00:00 2001
From: Antoine van der Lee <4329185+AvdLee@users.noreply.github.com>
Date: Wed, 27 May 2026 13:42:57 +0200
Subject: [PATCH 8/8] Avoid non-page docs breadcrumb URLs
Keep docs BreadcrumbList entries to real pages only: Home, Docs, and the current page. This avoids emitting sidebar group URLs such as /docs/getting-started/ that do not exist as standalone pages while keeping the current page label brand-aware.
---
docs/src/components/starlight/Head.astro | 24 +++++++++++++++++++-----
1 file changed, 19 insertions(+), 5 deletions(-)
diff --git a/docs/src/components/starlight/Head.astro b/docs/src/components/starlight/Head.astro
index e970b87a..1b1d84bf 100644
--- a/docs/src/components/starlight/Head.astro
+++ b/docs/src/components/starlight/Head.astro
@@ -47,6 +47,10 @@ const canonicalUrl = normalizeCanonicalUrl(Astro.url.href);
// 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.
@@ -61,12 +65,22 @@ const breadcrumbItems = isDocsRoute
name: "Home",
item: normalizeCanonicalUrl("/"),
},
- ...rawPathSegments.map((segment, index) => ({
+ {
"@type": "ListItem" as const,
- position: index + 2,
- name: slugToTitle(decodedPathSegments[index] ?? segment),
- item: pathToAbsoluteUrl(rawPathSegments.slice(0, index + 1)),
- })),
+ position: 2,
+ name: "Docs",
+ item: pathToAbsoluteUrl(["docs"]),
+ },
+ ...(rawPathSegments.length > 1
+ ? [
+ {
+ "@type": "ListItem" as const,
+ position: 3,
+ name: currentPageTitle,
+ item: canonicalUrl,
+ },
+ ]
+ : []),
]
: [];