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 ``, `<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> @@ -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 /> 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"; <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 --> { @@ -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} /> +<meta name="twitter:image:alt" content={ogImageAlt} /> <meta name="twitter:creator" content={twitterCreator} /> <meta name="twitter:site" content={twitterSite} /> 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) => ( - <script - type="application/ld+json" - is:inline - set:html={JSON.stringify(schema)} - /> - )) -} +<script + type="application/ld+json" + is:inline + set:html={JSON.stringify(structuredDataGraph)} +/> 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<string, string> = { + 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; + } +}