astro astro seo metadata
Seo metadata in astrojs
SazzadTanim
Blogger
Framework agnostic codes
// src/lib/seo/escapeHtml.ts
export const escapeHtml = (str: string | undefined | null): string => {
if (str == null) {
return "";
}
return str
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
};
// src/lib/seo/generateJsonLd.ts
/**
* Generates JSON-LD structured data for common schema types.
* @param schema - Schema configuration with type and data.
* @returns JSON-LD script tag string.
*/
export function generateJsonLd({
type,
data,
}: {
type: "Website" | "Article" | "Product";
data: Record<string, unknown>;
}): string {
const schema = {
"@context": "https://schema.org",
"@type": type,
...data,
};
return `<script type="application/ld+json">${JSON.stringify(schema, null, 2)}</script>`;
}
/** biome-ignore-all lint/style/noMagicNumbers: <> */
import z from "zod";
const REGEX = /\.(jpg|jpeg|png|webp|avif)$/i;
export const SeoMetadataSchema = z
.object({
// Required field: title (no optional, must be string)
title: z
.string()
.min(10, "Title should be at least 10 characters")
.max(70, "Title should not exceed 70 characters")
.default(
"This is your page title - make it compelling and under 70 characters"
),
// Optional fields with defaults (allow undefined, use default if undefined or null)
description: z
.string()
.min(50, "Description should be at least 50 characters")
.max(175, "Description should not exceed 160 characters")
.nullish()
.default(
"This is your page description - make it engaging and between 50-160 characters"
)
.optional(),
url: z
.url("Must be a valid URL")
.nullish()
.default("https://yoursite.com/this-page-url")
.optional(),
siteName: z.string().nullish().default("Your Awesome Website").optional(),
type: z
.enum(["website", "article", "product"])
.default("website")
.optional(),
image: z
.url("Image must be a valid URL")
.refine(
(val) => !val || REGEX.test(val),
"Image must be jpg, jpeg, png, webp, or avif"
)
.nullish()
.default("")
.optional(),
imageAlt: z
.string()
.max(125, "Image alt text should not exceed 125 characters")
.nullish()
.default("")
.optional(),
imageWidth: z
.number()
.min(1200, "Image width should be at least 1200px")
.default(1200)
.optional(),
imageHeight: z
.number()
.min(630, "Image height should be at least 630px")
.default(630)
.optional(),
author: z.string().nullish().default("").optional(),
useCanonical: z.boolean().default(true).optional(),
includeSiteNameInTitle: z.boolean().default(true).optional(),
titleSeparator: z.string().default("–").optional(),
locale: z.string().default("en_US").optional(),
htmlLang: z.string().default("en").optional(),
twitterCard: z
.enum(["summary", "summary_large_image", "app", "player"])
.default("summary")
.optional(),
twitterSite: z.string().startsWith("@").nullish().default("").optional(),
twitterCreator: z.string().startsWith("@").nullish().default("").optional(),
robots: z
.string()
.regex(
/^(index|noindex),(follow|nofollow)/,
"Invalid robots directive format"
)
.default("index,follow")
.optional(),
viewport: z
.string()
.default("width=device-width, initial-scale=1, shrink-to-fit=no")
.optional(),
charset: z.string().default("utf-8").optional(),
favicon: z.url().nullish().default("").optional(),
appleTouchIcon: z.url().nullish().default("").optional(),
// Article-specific
publishedTime: z.iso.datetime().nullish().default("").optional(),
modifiedTime: z.iso.datetime().nullish().default("").optional(),
articleAuthor: z.string().nullish().default("").optional(),
articleSection: z.string().nullish().default("").optional(),
articleTags: z.array(z.string()).default([]).optional(),
// Product-specific
productPrice: z.string().nullish().default("").optional(),
productCurrency: z.string().nullish().default("").optional(),
productAvailability: z
.enum(["instock", "outofstock", "preorder"])
.default("instock")
.optional(),
// Styling
themeColor: z
.string()
.regex(/^#[0-9A-Fa-f]{6}$/)
.default("#000000")
.optional(),
csp: z
.string()
.default(
"default-src 'self'; img-src 'self' data: https:; style-src 'self' 'unsafe-inline';"
)
.optional(),
})
.superRefine((data, ctx) => {
// Smart validation: Don't duplicate site name in title
if (
data.includeSiteNameInTitle &&
data.siteName &&
data.title.includes(data.siteName)
) {
ctx.addIssue({
code: "custom",
path: ["title"],
message:
"Title already includes site name; set includeSiteNameInTitle to false",
});
}
// Smart validation: Article fields should only be used with article type
if (
data.type !== "article" &&
(data.publishedTime || data.modifiedTime || data.articleAuthor)
) {
ctx.addIssue({
code: "custom",
message: "Article-specific fields provided but type is not 'article'",
});
}
});
export type SeoMetadata = z.infer<typeof SeoMetadataSchema>;
check more codes Nocodb Slugify Title
Share this article