All files / src/lib/seo article-schema.ts

100% Statements 20/20
100% Branches 25/25
100% Functions 5/5
100% Lines 15/15

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98                                          21x 5x 3x 2x 1x       22x 22x 22x 22x 22x     22x       31x   22x                                                                         4x 3x                                  
// Builds the schema.org Article JSON-LD object for blog posts.
// Extracted into a plain function so it can be unit-tested without Astro.
 
import type { BlogPost } from "../api/types";
 
export interface ArticleSchemaInput {
	blog: BlogPost;
	canonicalUrl: string;
	siteUrl?: string;
	// Pre-absolutized image URL. Required for schema.org spec — Google rejects
	// relative paths like "seed_pro_01/projects/.../cover.jpg". Callers should
	// resolve blog.featuredImageUrl via getImageUrl(...) before passing.
	image?: string;
}
 
// Absolutize an image path that may be:
//   1. Full URL (http://, https://) → keep as-is
//   2. Path starting with /api/images/ → prefix with SITE_URL
//   3. Bare R2 storage key (e.g. "seed_pro_01/.../x.jpg") → prefix with SITE_URL/api/images/
// Fallback to the site's default OG image when input is empty.
function absolutizeImage(raw: string | null | undefined, siteUrl: string): string {
	if (!raw) return `${siteUrl}/og/home.png`;
	if (raw.startsWith("http://") || raw.startsWith("https://")) return raw;
	if (raw.startsWith("/api/images/")) return `${siteUrl}${raw}`;
	if (raw.startsWith("/")) return `${siteUrl}${raw}`;
	return `${siteUrl}/api/images/${raw}`;
}
 
export function buildArticleSchema(input: ArticleSchemaInput): Record<string, unknown> {
	const { blog, canonicalUrl } = input;
	const SITE_URL = input.siteUrl ?? "https://interioring.com";
	const SITE_NAME = "Interioring";
	const LOGO_URL = `${SITE_URL}/logo.png`;
	const articleImage = input.image ?? absolutizeImage(blog.featuredImageUrl, SITE_URL);
 
	// Build deduplicated keywords array: primary first, then secondaries, no empties, no dupes.
	const rawKeywords: (string | null | undefined)[] = [
		blog.primaryKeyword,
		...(blog.secondaryKeywords ?? []),
	];
	const keywords = [...new Set(rawKeywords.filter((k): k is string => Boolean(k)))];
 
	return {
		"@context": "https://schema.org",
		// BlogPosting is Google's recommended, more-specific subtype of Article
		// for blog content; both trigger the same Article rich result.
		"@type": "BlogPosting",
		headline: blog.title,
		description: blog.metaDescription,
		image: articleImage,
		datePublished: blog.publishedAt,
		dateModified: blog.dateUpdated,
		// Distinct authoring entity (the editorial team) with a stable @id so it
		// is a separate node from the publisher. @id is an identifier, not a
		// fetched URL, so a homepage fragment is valid without a dedicated page.
		author: {
			"@type": "Organization",
			"@id": `${SITE_URL}#editorial-team`,
			name: `${SITE_NAME} Editorial Team`,
			url: SITE_URL,
		},
		// Publisher links to the site's single business entity (#organization),
		// consolidating it with the Organization/LocalBusiness nodes site-wide.
		publisher: {
			"@type": "Organization",
			"@id": `${SITE_URL}#organization`,
			name: SITE_NAME,
			url: SITE_URL,
			logo: {
				"@type": "ImageObject",
				url: LOGO_URL,
			},
		},
		mainEntityOfPage: {
			"@type": "WebPage",
			"@id": canonicalUrl,
		},
		...(blog.pros && blog.pros.length > 0 && {
			mentions: blog.pros
				.filter((v) => v.pro)
				.map((v) => ({
					"@type": "LocalBusiness",
					name: v.pro?.businessName,
					url: `${SITE_URL}/pros/${v.pro?.slug ?? v.pro?.id}`,
				})),
		}),
		...(keywords.length > 0 && {
			keywords: keywords.join(", "),
		}),
		...(blog.primaryKeyword && {
			about: {
				"@type": "Thing",
				name: blog.primaryKeyword,
			},
		}),
	};
}