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,
},
}),
};
}
|