logo

Navigation

Explore our products and services

Seo metadata in astrojs
astro astro seo metadata

Seo metadata in astrojs

SazzadTanim

SazzadTanim

Blogger

1 min

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, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
};
// 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

Back to Blog
SazzadTanim

About SazzadTanim

Blogger