diff --git a/docs/start/framework/react/guide/seo.md b/docs/start/framework/react/guide/seo.md index a4f580d446b..3ee6ee46a97 100644 --- a/docs/start/framework/react/guide/seo.md +++ b/docs/start/framework/react/guide/seo.md @@ -22,6 +22,7 @@ TanStack Start gives you the building blocks for technical SEO: - **Static Prerendering** - Pre-generates pages for optimal performance and crawlability - **Document Head Management** - Full control over meta tags, titles, and structured data - **Performance** - Fast load times through code-splitting, streaming, and optimal bundling +- **[@tanstack/meta](#using-tanstackmeta)** - Composable, type-safe utilities for meta tags and JSON-LD ## Document Head Management @@ -118,9 +119,135 @@ export const Route = createFileRoute('/posts/$postId')({ }) ``` +## Using @tanstack/meta + +For a more streamlined approach to meta tag management, you can use the `@tanstack/meta` package. It provides composable, type-safe utilities that handle the 90% use case with a single function call. + +### Installation + +```bash +npm install @tanstack/meta +``` + +### The createMeta Function + +The `createMeta` function generates a complete set of meta tags from a simple configuration: + +```tsx +import { createFileRoute } from '@tanstack/react-router' +import { createMeta } from '@tanstack/meta' + +export const Route = createFileRoute('/')({ + head: () => ({ + meta: createMeta({ + title: 'My App - Home', + description: 'Welcome to My App, a platform for...', + }), + }), + component: HomePage, +}) +``` + +This single call generates: + +- `` +- `` +- `My App - Home` +- `` +- Open Graph tags (`og:title`, `og:description`, `og:type`) +- Twitter Card tags (`twitter:card`, `twitter:title`, `twitter:description`) + +### Full Example with Social Sharing + +```tsx +import { createFileRoute } from '@tanstack/react-router' +import { createMeta } from '@tanstack/meta' + +export const Route = createFileRoute('/posts/$postId')({ + loader: async ({ params }) => { + const post = await fetchPost(params.postId) + return { post } + }, + head: ({ loaderData }) => ({ + meta: createMeta({ + title: loaderData.post.title, + description: loaderData.post.excerpt, + url: `https://myapp.com/posts/${loaderData.post.id}`, + image: loaderData.post.coverImage, + type: 'article', + siteName: 'My App', + twitterSite: '@myapp', + }), + }), + component: PostPage, +}) +``` + +### Extending with Custom Meta + +You can extend the generated meta with custom tags: + +```tsx +import { createMeta } from '@tanstack/meta' + +head: () => ({ + meta: createMeta({ + title: 'My Page', + description: 'Page description', + extend: [ + { name: 'author', content: 'John Doe' }, + { name: 'keywords', content: 'react, tanstack, router' }, + ], + }), +}) +``` + +### Using Individual Builders + +For more control, use the individual builder functions through the `meta` namespace: + +```tsx +import { meta } from '@tanstack/meta' + +head: () => ({ + meta: [ + ...meta.title('My Page', '%s | My App'), // With template + ...meta.description('Page description'), + ...meta.robots({ index: true, follow: true }), + ...meta.openGraph({ + title: 'My Page', + type: 'website', + image: 'https://myapp.com/og.jpg', + }), + ...meta.twitter({ + card: 'summary_large_image', + site: '@myapp', + }), + ], +}) +``` + +### Available Builders + +| Builder | Description | +|---------|-------------| +| `meta.title(value, template?)` | Page title with optional template | +| `meta.description(content)` | Meta description | +| `meta.charset()` | UTF-8 charset | +| `meta.viewport(content?)` | Viewport configuration | +| `meta.robots(config)` | Robot directives (index, follow, etc.) | +| `meta.canonical(href)` | Canonical URL link | +| `meta.alternate(links)` | Alternate language links | +| `meta.openGraph(config)` | Open Graph meta tags | +| `meta.twitter(config)` | Twitter Card meta tags | +| `meta.themeColor(color)` | Theme color (supports light/dark) | +| `meta.verification(config)` | Search engine verification codes | + ## Structured Data (JSON-LD) -Structured data helps search engines understand your content and can enable rich results in search: +Structured data helps search engines understand your content and can enable rich results in search. + +### Manual Approach ```tsx export const Route = createFileRoute('/posts/$postId')({ @@ -152,6 +279,106 @@ export const Route = createFileRoute('/posts/$postId')({ }) ``` +### Using @tanstack/meta/json-ld + +The `@tanstack/meta/json-ld` subpath provides type-safe builders for common Schema.org types: + +```tsx +import { createFileRoute } from '@tanstack/react-router' +import { createMeta } from '@tanstack/meta' +import { jsonLd } from '@tanstack/meta/json-ld' + +export const Route = createFileRoute('/posts/$postId')({ + loader: async ({ params }) => { + const post = await fetchPost(params.postId) + return { post } + }, + head: ({ loaderData }) => ({ + meta: [ + ...createMeta({ + title: loaderData.post.title, + description: loaderData.post.excerpt, + url: `https://myapp.com/posts/${loaderData.post.id}`, + image: loaderData.post.coverImage, + type: 'article', + }), + ...jsonLd.article({ + headline: loaderData.post.title, + description: loaderData.post.excerpt, + author: loaderData.post.author.name, + datePublished: loaderData.post.publishedAt, + image: loaderData.post.coverImage, + }), + ], + }), + component: PostPage, +}) +``` + +### Available JSON-LD Builders + +| Builder | Description | +|---------|-------------| +| `jsonLd.website(config)` | WebSite schema with optional search action | +| `jsonLd.organization(config)` | Organization with logo, socials, address | +| `jsonLd.person(config)` | Person schema | +| `jsonLd.article(config)` | Article, BlogPosting, NewsArticle | +| `jsonLd.product(config)` | Product with price, availability, ratings | +| `jsonLd.breadcrumbs(items)` | BreadcrumbList for navigation | +| `jsonLd.faq(items)` | FAQPage with questions and answers | +| `jsonLd.event(config)` | Event with location, dates | +| `jsonLd.localBusiness(config)` | LocalBusiness, Restaurant, Store | +| `jsonLd.softwareApp(config)` | SoftwareApplication | +| `jsonLd.video(config)` | VideoObject | +| `jsonLd.recipe(config)` | Recipe with ingredients, instructions | +| `jsonLd.course(config)` | Course with provider | +| `jsonLd.howTo(config)` | HowTo with steps | +| `jsonLd.create(schema)` | Raw JSON-LD for any schema type | + +### Product Page Example + +```tsx +import { createMeta } from '@tanstack/meta' +import { jsonLd } from '@tanstack/meta/json-ld' + +export const Route = createFileRoute('/products/$productId')({ + loader: async ({ params }) => { + const product = await fetchProduct(params.productId) + return { product } + }, + head: ({ loaderData }) => ({ + meta: [ + ...createMeta({ + title: loaderData.product.name, + description: loaderData.product.description, + url: `https://myapp.com/products/${loaderData.product.id}`, + image: loaderData.product.image, + type: 'product', + }), + ...jsonLd.product({ + name: loaderData.product.name, + description: loaderData.product.description, + image: loaderData.product.image, + price: loaderData.product.price, + currency: 'USD', + availability: 'InStock', + brand: loaderData.product.brand, + rating: { + value: loaderData.product.rating, + count: loaderData.product.reviewCount, + }, + }), + ...jsonLd.breadcrumbs([ + { name: 'Home', url: 'https://myapp.com' }, + { name: 'Products', url: 'https://myapp.com/products' }, + { name: loaderData.product.name, url: `https://myapp.com/products/${loaderData.product.id}` }, + ]), + ], + }), + component: ProductPage, +}) +``` + ## Server-Side Rendering SSR is enabled by default in TanStack Start. This ensures that search engine crawlers receive fully rendered HTML content, which is critical for SEO. diff --git a/examples/react/start-basic-react-query/package.json b/examples/react/start-basic-react-query/package.json index 86cf44d3703..e78858a6923 100644 --- a/examples/react/start-basic-react-query/package.json +++ b/examples/react/start-basic-react-query/package.json @@ -10,6 +10,7 @@ "start": "pnpx srvx --prod -s ../client dist/server/server.js" }, "dependencies": { + "@tanstack/meta": "workspace:*", "@tanstack/react-query": "^5.90.0", "@tanstack/react-query-devtools": "^5.90.0", "@tanstack/react-router": "^1.144.0", diff --git a/examples/react/start-basic-react-query/src/routes/__root.tsx b/examples/react/start-basic-react-query/src/routes/__root.tsx index bc278f8036b..b2ef4c07302 100644 --- a/examples/react/start-basic-react-query/src/routes/__root.tsx +++ b/examples/react/start-basic-react-query/src/routes/__root.tsx @@ -8,31 +8,25 @@ import { } from '@tanstack/react-router' import { ReactQueryDevtools } from '@tanstack/react-query-devtools' import { TanStackRouterDevtools } from '@tanstack/react-router-devtools' +import { createMeta } from '@tanstack/meta' import * as React from 'react' import type { QueryClient } from '@tanstack/react-query' import { DefaultCatchBoundary } from '~/components/DefaultCatchBoundary' import { NotFound } from '~/components/NotFound' import appCss from '~/styles/app.css?url' -import { seo } from '~/utils/seo' export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()({ head: () => ({ - meta: [ - { - charSet: 'utf-8', - }, - { - name: 'viewport', - content: 'width=device-width, initial-scale=1', - }, - ...seo({ - title: - 'TanStack Start | Type-Safe, Client-First, Full-Stack React Framework', - description: `TanStack Start is a type-safe, client-first, full-stack React framework. `, - }), - ], + meta: createMeta({ + title: + 'TanStack Start | Type-Safe, Client-First, Full-Stack React Framework', + description: + 'TanStack Start is a type-safe, client-first, full-stack React framework.', + twitterCreator: '@tanaborlabs', + twitterSite: '@tanaborlabs', + }), links: [ { rel: 'stylesheet', href: appCss }, { diff --git a/examples/react/start-basic-react-query/src/routes/posts.$postId.tsx b/examples/react/start-basic-react-query/src/routes/posts.$postId.tsx index e9e58e55411..4f211f9a7fa 100644 --- a/examples/react/start-basic-react-query/src/routes/posts.$postId.tsx +++ b/examples/react/start-basic-react-query/src/routes/posts.$postId.tsx @@ -1,5 +1,6 @@ import { ErrorComponent, Link, createFileRoute } from '@tanstack/react-router' import { useSuspenseQuery } from '@tanstack/react-query' +import { createMeta } from '@tanstack/meta' import { postQueryOptions } from '../utils/posts' import type { ErrorComponentProps } from '@tanstack/react-router' import { NotFound } from '~/components/NotFound' @@ -12,10 +13,23 @@ export const Route = createFileRoute('/posts/$postId')({ return { title: data.title, + body: data.body, + id: data.id, } }, head: ({ loaderData }) => ({ - meta: loaderData ? [{ title: loaderData.title }] : undefined, + meta: loaderData + ? createMeta({ + title: loaderData.title, + description: loaderData.body.slice(0, 160), + // In a real app, you'd use an actual URL + url: `https://example.com/posts/${loaderData.id}`, + titleTemplate: '%s | TanStack Start', + // Disable charset/viewport since root already provides them + charset: false, + viewport: false, + }) + : undefined, }), errorComponent: PostErrorComponent, notFoundComponent: () => { diff --git a/examples/react/start-basic-react-query/src/utils/seo.ts b/examples/react/start-basic-react-query/src/utils/seo.ts deleted file mode 100644 index d18ad84b74e..00000000000 --- a/examples/react/start-basic-react-query/src/utils/seo.ts +++ /dev/null @@ -1,33 +0,0 @@ -export const seo = ({ - title, - description, - keywords, - image, -}: { - title: string - description?: string - image?: string - keywords?: string -}) => { - const tags = [ - { title }, - { name: 'description', content: description }, - { name: 'keywords', content: keywords }, - { name: 'twitter:title', content: title }, - { name: 'twitter:description', content: description }, - { name: 'twitter:creator', content: '@tannerlinsley' }, - { name: 'twitter:site', content: '@tannerlinsley' }, - { name: 'og:type', content: 'website' }, - { name: 'og:title', content: title }, - { name: 'og:description', content: description }, - ...(image - ? [ - { name: 'twitter:image', content: image }, - { name: 'twitter:card', content: 'summary_large_image' }, - { name: 'og:image', content: image }, - ] - : []), - ] - - return tags -} diff --git a/packages/meta/package.json b/packages/meta/package.json new file mode 100644 index 00000000000..b2c1e872e96 --- /dev/null +++ b/packages/meta/package.json @@ -0,0 +1,79 @@ +{ + "name": "@tanstack/meta", + "version": "0.0.1", + "description": "Composable, type-safe meta tag and JSON-LD utilities for modern web applications", + "author": "Tanner Linsley", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/TanStack/router.git", + "directory": "packages/meta" + }, + "homepage": "https://tanstack.com/router", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "keywords": [ + "meta", + "seo", + "json-ld", + "structured-data", + "open-graph", + "twitter-cards", + "typescript" + ], + "scripts": { + "clean": "rimraf ./dist && rimraf ./coverage", + "test:eslint": "eslint ./src", + "test:types": "pnpm run \"/^test:types:ts[0-9]{2}$/\"", + "test:types:ts54": "node ../../node_modules/typescript54/lib/tsc.js", + "test:types:ts55": "node ../../node_modules/typescript55/lib/tsc.js", + "test:types:ts56": "node ../../node_modules/typescript56/lib/tsc.js", + "test:types:ts57": "node ../../node_modules/typescript57/lib/tsc.js", + "test:types:ts58": "node ../../node_modules/typescript58/lib/tsc.js", + "test:types:ts59": "tsc", + "test:build": "publint --strict && attw --ignore-rules no-resolution --pack .", + "test:unit": "vitest", + "test:unit:dev": "pnpm run test:unit --watch", + "build": "vite build" + }, + "type": "module", + "types": "dist/esm/index.d.ts", + "main": "dist/cjs/index.cjs", + "module": "dist/esm/index.js", + "exports": { + ".": { + "import": { + "types": "./dist/esm/index.d.ts", + "default": "./dist/esm/index.js" + }, + "require": { + "types": "./dist/cjs/index.d.cts", + "default": "./dist/cjs/index.cjs" + } + }, + "./json-ld": { + "import": { + "types": "./dist/esm/json-ld/index.d.ts", + "default": "./dist/esm/json-ld/index.js" + }, + "require": { + "types": "./dist/cjs/json-ld/index.d.cts", + "default": "./dist/cjs/json-ld/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "sideEffects": false, + "files": [ + "dist", + "src" + ], + "engines": { + "node": ">=12" + }, + "devDependencies": { + "esbuild": "^0.25.0" + } +} diff --git a/packages/meta/src/builders.ts b/packages/meta/src/builders.ts new file mode 100644 index 00000000000..dec139d1e0e --- /dev/null +++ b/packages/meta/src/builders.ts @@ -0,0 +1,321 @@ +import type { MetaDescriptor, MetaImage, RobotsConfig } from './types' + +// ───────────────────────────────────────────────────────────────────────────── +// Core Builders +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Creates a title meta descriptor + * + * @example + * ```ts + * meta.title('My Page') + * meta.title('My Page', '%s | My Site') // With template + * ``` + */ +export function title( + value: string, + template?: string, +): Array { + const formatted = template ? template.replace('%s', value) : value + return [{ title: formatted }] +} + +/** + * Creates a description meta descriptor + * + * @example + * ```ts + * meta.description('Page description here') + * ``` + */ +export function description(content: string): Array { + return [{ name: 'description', content }] +} + +/** + * Creates a charset meta descriptor (UTF-8) + */ +export function charset(): Array { + return [{ charSet: 'utf-8' }] +} + +/** + * Creates a viewport meta descriptor + * + * @example + * ```ts + * meta.viewport() // Default: width=device-width, initial-scale=1 + * meta.viewport('width=device-width, initial-scale=1, maximum-scale=5') + * ``` + */ +export function viewport( + content: string = 'width=device-width, initial-scale=1', +): Array { + return [{ name: 'viewport', content }] +} + +// ───────────────────────────────────────────────────────────────────────────── +// SEO Builders +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Creates robot directive meta descriptor + * + * @example + * ```ts + * meta.robots({ index: true, follow: true }) + * meta.robots({ index: false }) // noindex + * meta.robots({ maxSnippet: 160, maxImagePreview: 'large' }) + * ``` + */ +export function robots(config: RobotsConfig): Array { + const directives: Array = [] + + if (config.index === false) directives.push('noindex') + else if (config.index === true) directives.push('index') + + if (config.follow === false) directives.push('nofollow') + else if (config.follow === true) directives.push('follow') + + if (config.noarchive) directives.push('noarchive') + if (config.nosnippet) directives.push('nosnippet') + if (config.maxSnippet !== undefined) + directives.push(`max-snippet:${config.maxSnippet}`) + if (config.maxImagePreview) + directives.push(`max-image-preview:${config.maxImagePreview}`) + + if (directives.length === 0) return [] + + return [{ name: 'robots', content: directives.join(', ') }] +} + +/** + * Creates a canonical link descriptor + * + * @example + * ```ts + * meta.canonical('https://example.com/page') + * ``` + */ +export function canonical(href: string): Array { + return [{ tagName: 'link', rel: 'canonical', href }] +} + +/** + * Creates alternate language link descriptors + * + * @example + * ```ts + * meta.alternate([ + * { lang: 'en', href: 'https://example.com/en/page' }, + * { lang: 'es', href: 'https://example.com/es/page' }, + * { lang: 'x-default', href: 'https://example.com/page' }, + * ]) + * ``` + */ +export function alternate( + links: Array<{ lang: string; href: string }>, +): Array { + return links.map((link) => ({ + tagName: 'link', + rel: 'alternate', + hreflang: link.lang, + href: link.href, + })) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Open Graph Builders +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Creates Open Graph meta descriptors + * + * @example + * ```ts + * meta.openGraph({ + * title: 'My Page', + * description: 'Page description', + * type: 'website', + * url: 'https://example.com/page', + * image: 'https://example.com/og.jpg', + * }) + * ``` + */ +export function openGraph(config: { + title?: string + description?: string + type?: string + url?: string + siteName?: string + locale?: string + image?: string | MetaImage + images?: Array +}): Array { + const meta: Array = [] + + if (config.title) meta.push({ property: 'og:title', content: config.title }) + if (config.description) + meta.push({ property: 'og:description', content: config.description }) + if (config.type) meta.push({ property: 'og:type', content: config.type }) + if (config.url) meta.push({ property: 'og:url', content: config.url }) + if (config.siteName) + meta.push({ property: 'og:site_name', content: config.siteName }) + if (config.locale) + meta.push({ property: 'og:locale', content: config.locale }) + + const images = config.images ?? (config.image ? [config.image] : []) + for (const img of images) { + if (typeof img === 'string') { + meta.push({ property: 'og:image', content: img }) + } else { + meta.push({ property: 'og:image', content: img.url }) + if (img.width) + meta.push({ property: 'og:image:width', content: String(img.width) }) + if (img.height) + meta.push({ property: 'og:image:height', content: String(img.height) }) + if (img.alt) meta.push({ property: 'og:image:alt', content: img.alt }) + } + } + + return meta +} + +// ───────────────────────────────────────────────────────────────────────────── +// Twitter Card Builders +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Creates Twitter Card meta descriptors + * + * @example + * ```ts + * meta.twitter({ + * card: 'summary_large_image', + * title: 'My Page', + * description: 'Page description', + * image: 'https://example.com/twitter.jpg', + * site: '@mysite', + * }) + * ``` + */ +export function twitter(config: { + card?: 'summary' | 'summary_large_image' | 'app' | 'player' + title?: string + description?: string + image?: string + imageAlt?: string + site?: string + creator?: string +}): Array { + const meta: Array = [] + + if (config.card) meta.push({ name: 'twitter:card', content: config.card }) + if (config.title) meta.push({ name: 'twitter:title', content: config.title }) + if (config.description) + meta.push({ name: 'twitter:description', content: config.description }) + if (config.image) meta.push({ name: 'twitter:image', content: config.image }) + if (config.imageAlt) + meta.push({ name: 'twitter:image:alt', content: config.imageAlt }) + if (config.site) meta.push({ name: 'twitter:site', content: config.site }) + if (config.creator) + meta.push({ name: 'twitter:creator', content: config.creator }) + + return meta +} + +// ───────────────────────────────────────────────────────────────────────────── +// Utility Builders +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Creates theme-color meta descriptor(s) + * + * @example + * ```ts + * meta.themeColor('#ffffff') + * meta.themeColor({ light: '#ffffff', dark: '#000000' }) + * ``` + */ +export function themeColor( + color: string | { light: string; dark: string }, +): Array { + if (typeof color === 'string') { + return [{ name: 'theme-color', content: color }] + } + return [ + { + name: 'theme-color', + content: color.light, + media: '(prefers-color-scheme: light)', + } as MetaDescriptor, + { + name: 'theme-color', + content: color.dark, + media: '(prefers-color-scheme: dark)', + } as MetaDescriptor, + ] +} + +/** + * Creates verification meta descriptors for search engines + * + * @example + * ```ts + * meta.verification({ google: 'code', bing: 'code' }) + * ``` + */ +export function verification(config: { + google?: string + bing?: string + yandex?: string + pinterest?: string +}): Array { + const meta: Array = [] + + if (config.google) + meta.push({ name: 'google-site-verification', content: config.google }) + if (config.bing) meta.push({ name: 'msvalidate.01', content: config.bing }) + if (config.yandex) + meta.push({ name: 'yandex-verification', content: config.yandex }) + if (config.pinterest) + meta.push({ name: 'p:domain_verify', content: config.pinterest }) + + return meta +} + +// ───────────────────────────────────────────────────────────────────────────── +// Meta Namespace Object +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Namespace object containing all meta builders. + * Provides a convenient way to access builders with autocomplete. + * + * @example + * ```ts + * import { meta } from '@tanstack/meta' + * + * head: () => ({ + * meta: [ + * ...meta.title('My Page'), + * ...meta.description('Description'), + * ...meta.openGraph({ title: 'My Page', type: 'website' }), + * ], + * }) + * ``` + */ +export const meta = { + title, + description, + charset, + viewport, + robots, + canonical, + alternate, + openGraph, + twitter, + themeColor, + verification, +} as const diff --git a/packages/meta/src/createMeta.ts b/packages/meta/src/createMeta.ts new file mode 100644 index 00000000000..74430d86894 --- /dev/null +++ b/packages/meta/src/createMeta.ts @@ -0,0 +1,423 @@ +import type { + MetaDescriptor, + MetaImage, + OpenGraphConfig, + RobotsConfig, + TwitterConfig, +} from './types' + +/** + * Configuration for createMeta - the main entry point for generating meta tags. + * + * @example + * ```ts + * // Basic usage - covers 90% of use cases + * createMeta({ + * title: 'My Page', + * description: 'A great page about something', + * }) + * + * // Full configuration + * createMeta({ + * title: 'Product Name', + * description: 'Product description', + * url: 'https://example.com/product', + * image: 'https://example.com/og.jpg', + * siteName: 'My Store', + * type: 'product', + * }) + * ``` + */ +export interface CreateMetaConfig { + // ───────────────────────────────────────────────────────────────────────── + // Required - The bare minimum for any page + // ───────────────────────────────────────────────────────────────────────── + + /** + * Page title - used for , og:title, and twitter:title + */ + title: string + + /** + * Page description - used for description, og:description, and twitter:description + */ + description: string + + // ───────────────────────────────────────────────────────────────────────── + // Recommended - Significantly improves SEO and social sharing + // ───────────────────────────────────────────────────────────────────────── + + /** + * Canonical URL of the page - used for canonical link and og:url + */ + url?: string + + /** + * Primary image for social sharing - used for og:image and twitter:image + * Can be a URL string or an object with dimensions + * + * @example + * ```ts + * // Simple URL + * image: 'https://example.com/og.jpg' + * + * // With dimensions (recommended for better rendering) + * image: { + * url: 'https://example.com/og.jpg', + * width: 1200, + * height: 630, + * alt: 'Description of image' + * } + * ``` + */ + image?: string | MetaImage + + // ───────────────────────────────────────────────────────────────────────── + // Optional - Fine-tune behavior + // ───────────────────────────────────────────────────────────────────────── + + /** + * Template for the title, use %s as placeholder + * @example '%s | My Site' results in 'Page Title | My Site' + */ + titleTemplate?: string + + /** + * Name of the website/application + */ + siteName?: string + + /** + * Content type for Open Graph + * @default 'website' + */ + type?: 'website' | 'article' | 'product' | 'profile' | string + + /** + * Locale for Open Graph (e.g., 'en_US') + */ + locale?: string + + /** + * Twitter @username for the website + */ + twitterSite?: string + + /** + * Twitter @username of the content creator + */ + twitterCreator?: string + + /** + * Theme color for browser chrome + * Can be a single color or light/dark mode colors + * + * @example + * ```ts + * themeColor: '#ffffff' + * // or + * themeColor: { light: '#ffffff', dark: '#000000' } + * ``` + */ + themeColor?: string | { light: string; dark: string } + + // ───────────────────────────────────────────────────────────────────────── + // Overrides - Full control when you need it + // ───────────────────────────────────────────────────────────────────────── + + /** + * Override robot directives + * @default { index: true, follow: true } + */ + robots?: RobotsConfig + + /** + * Override Open Graph properties + * Values here will override the inferred ones + */ + openGraph?: OpenGraphConfig + + /** + * Override Twitter Card properties + * Values here will override the inferred ones + */ + twitter?: TwitterConfig + + // ───────────────────────────────────────────────────────────────────────── + // Control what's included + // ───────────────────────────────────────────────────────────────────────── + + /** + * Include charset meta tag + * @default true + */ + charset?: boolean + + /** + * Include viewport meta tag + * @default true + */ + viewport?: boolean | string + + /** + * Include canonical link + * @default true when url is provided + */ + canonical?: boolean + + /** + * Additional meta descriptors to append + * These are added after the generated ones + */ + extend?: Array<MetaDescriptor> +} + +/** + * Creates a complete set of meta tags for a page. + * + * This is the primary API for @tanstack/meta - it handles the 90% use case + * of generating proper meta tags for SEO and social sharing. + * + * @example + * ```ts + * import { createMeta } from '@tanstack/meta' + * + * // In a TanStack Router route + * export const Route = createFileRoute('/about')({ + * head: () => ({ + * meta: createMeta({ + * title: 'About Us', + * description: 'Learn about our company', + * url: 'https://example.com/about', + * image: 'https://example.com/about-og.jpg', + * }), + * }), + * }) + * ``` + * + * @example + * ```ts + * // Extending with custom meta or JSON-LD + * import { createMeta } from '@tanstack/meta' + * import { jsonLd } from '@tanstack/meta/json-ld' + * + * head: () => ({ + * meta: [ + * ...createMeta({ + * title: 'Product Name', + * description: 'Product description', + * }), + * ...jsonLd.product({ + * name: 'Product Name', + * price: 99.99, + * currency: 'USD', + * }), + * ], + * }) + * ``` + * + * @returns Array of MetaDescriptor objects + */ +export function createMeta(config: CreateMetaConfig): Array<MetaDescriptor> { + const { + title, + description, + url, + image, + titleTemplate, + siteName, + type = 'website', + locale, + twitterSite, + twitterCreator, + themeColor, + robots, + openGraph, + twitter, + charset = true, + viewport = true, + canonical = true, + extend, + } = config + + const meta: Array<MetaDescriptor> = [] + + // ───────────────────────────────────────────────────────────────────────── + // Essential meta tags + // ───────────────────────────────────────────────────────────────────────── + + // Charset + if (charset) { + meta.push({ charSet: 'utf-8' }) + } + + // Viewport + if (viewport) { + const viewportContent = + typeof viewport === 'string' + ? viewport + : 'width=device-width, initial-scale=1' + meta.push({ name: 'viewport', content: viewportContent }) + } + + // Title + const formattedTitle = titleTemplate + ? titleTemplate.replace('%s', title) + : title + meta.push({ title: formattedTitle }) + + // Description + meta.push({ name: 'description', content: description }) + + // ───────────────────────────────────────────────────────────────────────── + // Robots + // ───────────────────────────────────────────────────────────────────────── + + if (robots) { + const directives: Array<string> = [] + + if (robots.index === false) directives.push('noindex') + else if (robots.index === true) directives.push('index') + + if (robots.follow === false) directives.push('nofollow') + else if (robots.follow === true) directives.push('follow') + + if (robots.noarchive) directives.push('noarchive') + if (robots.nosnippet) directives.push('nosnippet') + if (robots.maxSnippet !== undefined) + directives.push(`max-snippet:${robots.maxSnippet}`) + if (robots.maxImagePreview) + directives.push(`max-image-preview:${robots.maxImagePreview}`) + + if (directives.length > 0) { + meta.push({ name: 'robots', content: directives.join(', ') }) + } + } + + // ───────────────────────────────────────────────────────────────────────── + // Canonical URL + // ───────────────────────────────────────────────────────────────────────── + + if (url && canonical) { + meta.push({ tagName: 'link', rel: 'canonical', href: url }) + } + + // ───────────────────────────────────────────────────────────────────────── + // Open Graph + // ───────────────────────────────────────────────────────────────────────── + + const ogTitle = openGraph?.title ?? title + const ogDescription = openGraph?.description ?? description + const ogType = openGraph?.type ?? type + const ogUrl = openGraph?.url ?? url + const ogSiteName = openGraph?.siteName ?? siteName + const ogLocale = openGraph?.locale ?? locale + const ogImages = openGraph?.images ?? (image ? [image] : []) + + meta.push({ property: 'og:title', content: ogTitle }) + meta.push({ property: 'og:description', content: ogDescription }) + meta.push({ property: 'og:type', content: ogType }) + + if (ogUrl) meta.push({ property: 'og:url', content: ogUrl }) + if (ogSiteName) meta.push({ property: 'og:site_name', content: ogSiteName }) + if (ogLocale) meta.push({ property: 'og:locale', content: ogLocale }) + + for (const img of ogImages) { + if (typeof img === 'string') { + meta.push({ property: 'og:image', content: img }) + } else { + meta.push({ property: 'og:image', content: img.url }) + if (img.width) + meta.push({ property: 'og:image:width', content: String(img.width) }) + if (img.height) + meta.push({ property: 'og:image:height', content: String(img.height) }) + if (img.alt) meta.push({ property: 'og:image:alt', content: img.alt }) + } + } + + // Article-specific properties + if (openGraph?.article) { + const article = openGraph.article + if (article.publishedTime) { + meta.push({ + property: 'article:published_time', + content: article.publishedTime, + }) + } + if (article.modifiedTime) { + meta.push({ + property: 'article:modified_time', + content: article.modifiedTime, + }) + } + if (article.section) { + meta.push({ property: 'article:section', content: article.section }) + } + if (article.authors) { + for (const author of article.authors) { + meta.push({ property: 'article:author', content: author }) + } + } + if (article.tags) { + for (const tag of article.tags) { + meta.push({ property: 'article:tag', content: tag }) + } + } + } + + // ───────────────────────────────────────────────────────────────────────── + // Twitter Card + // ───────────────────────────────────────────────────────────────────────── + + const hasImage = ogImages.length > 0 + const twitterCard = twitter?.card ?? (hasImage ? 'summary_large_image' : 'summary') + const twitterTitle = twitter?.title ?? title + const twitterDescription = twitter?.description ?? description + const twitterImage = twitter?.image ?? (typeof image === 'string' ? image : image?.url) + const twitterImageAlt = twitter?.imageAlt ?? (typeof image === 'object' ? image?.alt : undefined) + const twitterSiteHandle = twitter?.site ?? twitterSite + const twitterCreatorHandle = twitter?.creator ?? twitterCreator + + meta.push({ name: 'twitter:card', content: twitterCard }) + meta.push({ name: 'twitter:title', content: twitterTitle }) + meta.push({ name: 'twitter:description', content: twitterDescription }) + + if (twitterImage) meta.push({ name: 'twitter:image', content: twitterImage }) + if (twitterImageAlt) + meta.push({ name: 'twitter:image:alt', content: twitterImageAlt }) + if (twitterSiteHandle) + meta.push({ name: 'twitter:site', content: twitterSiteHandle }) + if (twitterCreatorHandle) + meta.push({ name: 'twitter:creator', content: twitterCreatorHandle }) + + // ───────────────────────────────────────────────────────────────────────── + // Theme Color + // ───────────────────────────────────────────────────────────────────────── + + if (themeColor) { + if (typeof themeColor === 'string') { + meta.push({ name: 'theme-color', content: themeColor }) + } else { + meta.push({ + name: 'theme-color', + content: themeColor.light, + media: '(prefers-color-scheme: light)', + } as MetaDescriptor) + meta.push({ + name: 'theme-color', + content: themeColor.dark, + media: '(prefers-color-scheme: dark)', + } as MetaDescriptor) + } + } + + // ───────────────────────────────────────────────────────────────────────── + // Extensions + // ───────────────────────────────────────────────────────────────────────── + + if (extend) { + meta.push(...extend) + } + + return meta +} diff --git a/packages/meta/src/index.ts b/packages/meta/src/index.ts new file mode 100644 index 00000000000..91037e3af44 --- /dev/null +++ b/packages/meta/src/index.ts @@ -0,0 +1,98 @@ +/** + * @tanstack/meta + * + * Composable, type-safe meta tag utilities for modern web applications. + * + * ## Quick Start + * + * ```ts + * import { createMeta } from '@tanstack/meta' + * + * // In a TanStack Router route + * export const Route = createFileRoute('/about')({ + * head: () => ({ + * meta: createMeta({ + * title: 'About Us', + * description: 'Learn about our company', + * url: 'https://example.com/about', + * image: 'https://example.com/about-og.jpg', + * }), + * }), + * }) + * ``` + * + * ## Extending with JSON-LD + * + * ```ts + * import { createMeta } from '@tanstack/meta' + * import { jsonLd } from '@tanstack/meta/json-ld' + * + * head: () => ({ + * meta: [ + * ...createMeta({ title: 'Product', description: 'Great product' }), + * ...jsonLd.product({ name: 'Product', price: 99.99 }), + * ], + * }) + * ``` + * + * ## Using Individual Builders + * + * ```ts + * import { meta } from '@tanstack/meta' + * + * head: () => ({ + * meta: [ + * ...meta.title('My Page'), + * ...meta.description('Page description'), + * ...meta.robots({ index: true, follow: true }), + * ], + * }) + * ``` + */ + +// ───────────────────────────────────────────────────────────────────────────── +// Primary API +// ───────────────────────────────────────────────────────────────────────────── + +export { createMeta } from './createMeta' +export type { CreateMetaConfig } from './createMeta' + +// ───────────────────────────────────────────────────────────────────────────── +// Individual Builders +// ───────────────────────────────────────────────────────────────────────────── + +export { + // Builder namespace + meta, + // Individual functions for tree-shaking + title, + description, + charset, + viewport, + robots, + canonical, + alternate, + openGraph, + twitter, + themeColor, + verification, +} from './builders' + +// ───────────────────────────────────────────────────────────────────────────── +// Merge Utilities +// ───────────────────────────────────────────────────────────────────────────── + +export { mergeMeta, mergeMetaWith, excludeMeta, pickMeta } from './merge' +export type { MergeStrategy, MergeOptions } from './merge' + +// ───────────────────────────────────────────────────────────────────────────── +// Types +// ───────────────────────────────────────────────────────────────────────────── + +export type { + MetaDescriptor, + MetaImage, + RobotsConfig, + OpenGraphConfig, + TwitterConfig, +} from './types' diff --git a/packages/meta/src/json-ld/builders.ts b/packages/meta/src/json-ld/builders.ts new file mode 100644 index 00000000000..701ca0dc8f0 --- /dev/null +++ b/packages/meta/src/json-ld/builders.ts @@ -0,0 +1,807 @@ +import type { MetaDescriptor } from '../types' +import type { + Article, + Course, + Event, + FAQPage, + HowTo, + LocalBusiness, + Organization, + Person, + Product, + Recipe, + SoftwareApplication, + Thing, + Video, + WebPage, + WebSite, +} from './types' + +// ───────────────────────────────────────────────────────────────────────────── +// Core JSON-LD Builder +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Creates a JSON-LD script meta descriptor. + * + * This is the low-level builder for any Schema.org type. + * For common types, prefer the specific builders like `jsonLd.product()`. + * + * @example + * ```ts + * import { jsonLd } from '@tanstack/meta/json-ld' + * + * // Single schema + * jsonLd.create({ + * '@type': 'WebSite', + * name: 'My Site', + * url: 'https://example.com', + * }) + * + * // Multiple schemas with @graph + * jsonLd.create([ + * { '@type': 'WebSite', name: 'My Site' }, + * { '@type': 'Organization', name: 'My Org' }, + * ]) + * ``` + */ +function create<T extends Thing>(schema: T | Array<T>): Array<MetaDescriptor> { + const document = Array.isArray(schema) + ? { '@context': 'https://schema.org', '@graph': schema } + : { '@context': 'https://schema.org', ...schema } + + return [{ 'script:ld+json': document }] +} + +// ───────────────────────────────────────────────────────────────────────────── +// Simplified Builders +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Creates a WebSite JSON-LD schema + * + * @example + * ```ts + * jsonLd.website({ + * name: 'My Site', + * url: 'https://example.com', + * searchUrl: 'https://example.com/search?q={query}', + * }) + * ``` + */ +function website(config: { + name: string + url?: string + description?: string + searchUrl?: string +}): Array<MetaDescriptor> { + const schema: WebSite = { + '@type': 'WebSite', + name: config.name, + url: config.url, + description: config.description, + } + + if (config.searchUrl) { + schema.potentialAction = { + '@type': 'SearchAction', + target: config.searchUrl.includes('{') + ? { '@type': 'EntryPoint', urlTemplate: config.searchUrl } + : config.searchUrl, + 'query-input': 'required name=query', + } + } + + return create(schema) +} + +/** + * Creates an Organization JSON-LD schema + * + * @example + * ```ts + * jsonLd.organization({ + * name: 'My Company', + * url: 'https://example.com', + * logo: 'https://example.com/logo.png', + * socials: ['https://twitter.com/company', 'https://linkedin.com/company/...'], + * }) + * ``` + */ +function organization(config: { + name: string + url?: string + logo?: string + description?: string + email?: string + telephone?: string + address?: { street?: string; city?: string; region?: string; postal?: string; country?: string } + socials?: Array<string> +}): Array<MetaDescriptor> { + const schema: Organization = { + '@type': 'Organization', + name: config.name, + url: config.url, + description: config.description, + logo: config.logo, + email: config.email, + telephone: config.telephone, + sameAs: config.socials, + } + + if (config.address) { + schema.address = { + '@type': 'PostalAddress', + streetAddress: config.address.street, + addressLocality: config.address.city, + addressRegion: config.address.region, + postalCode: config.address.postal, + addressCountry: config.address.country, + } + } + + return create(schema) +} + +/** + * Creates a Person JSON-LD schema + * + * @example + * ```ts + * jsonLd.person({ + * name: 'John Doe', + * url: 'https://johndoe.com', + * jobTitle: 'Software Engineer', + * }) + * ``` + */ +function person(config: { + name: string + url?: string + image?: string + jobTitle?: string + email?: string + socials?: Array<string> +}): Array<MetaDescriptor> { + const schema: Person = { + '@type': 'Person', + name: config.name, + url: config.url, + image: config.image, + jobTitle: config.jobTitle, + email: config.email, + sameAs: config.socials, + } + + return create(schema) +} + +/** + * Creates an Article JSON-LD schema + * + * @example + * ```ts + * jsonLd.article({ + * headline: 'Article Title', + * description: 'Article description', + * author: 'John Doe', + * datePublished: '2024-01-15', + * image: 'https://example.com/image.jpg', + * }) + * ``` + */ +function article(config: { + headline: string + description?: string + author?: string | { name: string; url?: string } + datePublished?: string + dateModified?: string + image?: string + section?: string + keywords?: Array<string> + type?: 'Article' | 'NewsArticle' | 'BlogPosting' | 'TechArticle' +}): Array<MetaDescriptor> { + const authorSchema: Person | undefined = config.author + ? typeof config.author === 'string' + ? { '@type': 'Person', name: config.author } + : { '@type': 'Person', name: config.author.name, url: config.author.url } + : undefined + + const schema: Article = { + '@type': config.type ?? 'Article', + headline: config.headline, + description: config.description, + author: authorSchema, + datePublished: config.datePublished, + dateModified: config.dateModified, + image: config.image, + articleSection: config.section, + keywords: config.keywords, + } + + return create(schema) +} + +/** + * Creates a Product JSON-LD schema + * + * @example + * ```ts + * jsonLd.product({ + * name: 'Cool Product', + * description: 'A very cool product', + * image: 'https://example.com/product.jpg', + * price: 99.99, + * currency: 'USD', + * availability: 'InStock', + * brand: 'My Brand', + * }) + * ``` + */ +function product(config: { + name: string + description?: string + image?: string + price?: number + currency?: string + availability?: 'InStock' | 'OutOfStock' | 'PreOrder' | string + brand?: string + sku?: string + rating?: { value: number; count?: number } +}): Array<MetaDescriptor> { + const schema: Product = { + '@type': 'Product', + name: config.name, + description: config.description, + image: config.image, + sku: config.sku, + } + + if (config.brand) { + schema.brand = { '@type': 'Brand', name: config.brand } + } + + if (config.price !== undefined) { + const availability = config.availability + ? config.availability.startsWith('https://') + ? config.availability + : `https://schema.org/${config.availability}` + : undefined + + schema.offers = { + '@type': 'Offer', + price: config.price, + priceCurrency: config.currency ?? 'USD', + availability, + } + } + + if (config.rating) { + schema.aggregateRating = { + '@type': 'AggregateRating', + ratingValue: config.rating.value, + ratingCount: config.rating.count, + } + } + + return create(schema) +} + +/** + * Creates a BreadcrumbList JSON-LD schema + * + * @example + * ```ts + * jsonLd.breadcrumbs([ + * { name: 'Home', url: 'https://example.com' }, + * { name: 'Products', url: 'https://example.com/products' }, + * { name: 'Widget', url: 'https://example.com/products/widget' }, + * ]) + * ``` + */ +function breadcrumbs( + items: Array<{ name: string; url: string }>, +): Array<MetaDescriptor> { + return create({ + '@type': 'BreadcrumbList', + itemListElement: items.map((item, index) => ({ + '@type': 'ListItem', + position: index + 1, + name: item.name, + item: item.url, + })), + }) +} + +/** + * Creates a FAQPage JSON-LD schema + * + * @example + * ```ts + * jsonLd.faq([ + * { question: 'What is...?', answer: 'It is...' }, + * { question: 'How do I...?', answer: 'You can...' }, + * ]) + * ``` + */ +function faq( + items: Array<{ question: string; answer: string }>, +): Array<MetaDescriptor> { + const schema: FAQPage = { + '@type': 'FAQPage', + mainEntity: items.map((item) => ({ + '@type': 'Question', + name: item.question, + acceptedAnswer: { + '@type': 'Answer', + text: item.answer, + }, + })), + } + + return create(schema) +} + +/** + * Creates an Event JSON-LD schema + * + * @example + * ```ts + * jsonLd.event({ + * name: 'Concert', + * startDate: '2024-06-15T19:00:00-07:00', + * endDate: '2024-06-15T22:00:00-07:00', + * location: 'Madison Square Garden, New York', + * // or + * location: { name: 'Madison Square Garden', address: 'NYC' }, + * }) + * ``` + */ +function event(config: { + name: string + description?: string + startDate?: string + endDate?: string + location?: string | { name: string; address?: string; url?: string } + image?: string + organizer?: string + url?: string + type?: Event['@type'] +}): Array<MetaDescriptor> { + let locationSchema: Event['location'] + + if (config.location) { + if (typeof config.location === 'string') { + locationSchema = config.location + } else if (config.location.url) { + locationSchema = { + '@type': 'VirtualLocation', + url: config.location.url, + } + } else { + locationSchema = { + '@type': 'Place', + name: config.location.name, + address: config.location.address, + } + } + } + + const schema: Event = { + '@type': config.type ?? 'Event', + name: config.name, + description: config.description, + startDate: config.startDate, + endDate: config.endDate, + location: locationSchema, + image: config.image, + url: config.url, + organizer: config.organizer + ? { '@type': 'Organization', name: config.organizer } + : undefined, + } + + return create(schema) +} + +/** + * Creates a LocalBusiness JSON-LD schema + * + * @example + * ```ts + * jsonLd.localBusiness({ + * name: 'My Restaurant', + * type: 'Restaurant', + * address: '123 Main St, City, State 12345', + * telephone: '+1-555-555-5555', + * openingHours: 'Mo-Fr 09:00-17:00', + * priceRange: '$$', + * }) + * ``` + */ +function localBusiness(config: { + name: string + type?: 'LocalBusiness' | 'Restaurant' | 'Store' | string + description?: string + url?: string + image?: string + telephone?: string + email?: string + address?: string | { street?: string; city?: string; region?: string; postal?: string; country?: string } + openingHours?: string | Array<string> + priceRange?: string + rating?: { value: number; count?: number } +}): Array<MetaDescriptor> { + let addressSchema: LocalBusiness['address'] + + if (config.address) { + if (typeof config.address === 'string') { + addressSchema = config.address + } else { + addressSchema = { + '@type': 'PostalAddress', + streetAddress: config.address.street, + addressLocality: config.address.city, + addressRegion: config.address.region, + postalCode: config.address.postal, + addressCountry: config.address.country, + } + } + } + + const schema: LocalBusiness = { + '@type': config.type ?? 'LocalBusiness', + name: config.name, + description: config.description, + url: config.url, + image: config.image, + telephone: config.telephone, + email: config.email, + address: addressSchema, + openingHours: config.openingHours, + priceRange: config.priceRange, + } + + if (config.rating) { + schema.aggregateRating = { + '@type': 'AggregateRating', + ratingValue: config.rating.value, + ratingCount: config.rating.count, + } + } + + return create(schema) +} + +/** + * Creates a SoftwareApplication JSON-LD schema + * + * @example + * ```ts + * jsonLd.softwareApp({ + * name: 'My App', + * type: 'MobileApplication', + * operatingSystem: 'iOS, Android', + * category: 'GameApplication', + * price: 0, + * rating: { value: 4.5, count: 1000 }, + * }) + * ``` + */ +function softwareApp(config: { + name: string + type?: 'SoftwareApplication' | 'MobileApplication' | 'WebApplication' + description?: string + url?: string + operatingSystem?: string + category?: string + price?: number + currency?: string + rating?: { value: number; count?: number } +}): Array<MetaDescriptor> { + const schema: SoftwareApplication = { + '@type': config.type ?? 'SoftwareApplication', + name: config.name, + description: config.description, + url: config.url, + operatingSystem: config.operatingSystem, + applicationCategory: config.category, + } + + if (config.price !== undefined) { + schema.offers = { + '@type': 'Offer', + price: config.price, + priceCurrency: config.currency ?? 'USD', + } + } + + if (config.rating) { + schema.aggregateRating = { + '@type': 'AggregateRating', + ratingValue: config.rating.value, + ratingCount: config.rating.count, + } + } + + return create(schema) +} + +/** + * Creates a VideoObject JSON-LD schema + * + * @example + * ```ts + * jsonLd.video({ + * name: 'Video Title', + * description: 'Video description', + * thumbnail: 'https://example.com/thumb.jpg', + * uploadDate: '2024-01-15', + * contentUrl: 'https://example.com/video.mp4', + * duration: 'PT5M30S', // 5 minutes 30 seconds + * }) + * ``` + */ +function video(config: { + name: string + description?: string + thumbnail?: string + uploadDate?: string + contentUrl?: string + embedUrl?: string + duration?: string +}): Array<MetaDescriptor> { + const schema: Video = { + '@type': 'VideoObject', + name: config.name, + description: config.description, + thumbnailUrl: config.thumbnail, + uploadDate: config.uploadDate, + contentUrl: config.contentUrl, + embedUrl: config.embedUrl, + duration: config.duration, + } + + return create(schema) +} + +/** + * Creates a Recipe JSON-LD schema + * + * @example + * ```ts + * jsonLd.recipe({ + * name: 'Chocolate Cake', + * description: 'Delicious chocolate cake', + * author: 'Chef John', + * prepTime: 'PT30M', + * cookTime: 'PT1H', + * servings: '8 servings', + * ingredients: ['2 cups flour', '1 cup sugar', '...'], + * }) + * ``` + */ +function recipe(config: { + name: string + description?: string + author?: string + image?: string + prepTime?: string + cookTime?: string + totalTime?: string + servings?: string + ingredients?: Array<string> + instructions?: string | Array<string> + category?: string + cuisine?: string + keywords?: string + rating?: { value: number; count?: number } +}): Array<MetaDescriptor> { + const schema: Recipe = { + '@type': 'Recipe', + name: config.name, + description: config.description, + image: config.image, + prepTime: config.prepTime, + cookTime: config.cookTime, + totalTime: config.totalTime, + recipeYield: config.servings, + recipeIngredient: config.ingredients, + recipeCategory: config.category, + recipeCuisine: config.cuisine, + keywords: config.keywords, + } + + if (config.author) { + schema.author = { '@type': 'Person', name: config.author } + } + + if (config.instructions) { + if (typeof config.instructions === 'string') { + schema.recipeInstructions = config.instructions + } else { + schema.recipeInstructions = config.instructions.map((text) => ({ + '@type': 'HowToStep', + text, + })) + } + } + + if (config.rating) { + schema.aggregateRating = { + '@type': 'AggregateRating', + ratingValue: config.rating.value, + ratingCount: config.rating.count, + } + } + + return create(schema) +} + +/** + * Creates a Course JSON-LD schema + * + * @example + * ```ts + * jsonLd.course({ + * name: 'Introduction to Programming', + * description: 'Learn the basics', + * provider: 'Online Academy', + * url: 'https://example.com/course', + * }) + * ``` + */ +function course(config: { + name: string + description?: string + provider?: string + url?: string + image?: string +}): Array<MetaDescriptor> { + const schema: Course = { + '@type': 'Course', + name: config.name, + description: config.description, + url: config.url, + image: config.image, + } + + if (config.provider) { + schema.provider = { '@type': 'Organization', name: config.provider } + } + + return create(schema) +} + +/** + * Creates a HowTo JSON-LD schema + * + * @example + * ```ts + * jsonLd.howTo({ + * name: 'How to Make Coffee', + * description: 'A simple guide', + * totalTime: 'PT5M', + * steps: [ + * 'Boil water', + * 'Add coffee grounds', + * 'Pour water over grounds', + * 'Wait 4 minutes', + * ], + * }) + * ``` + */ +function howTo(config: { + name: string + description?: string + totalTime?: string + steps: Array<string | { name?: string; text: string; image?: string }> +}): Array<MetaDescriptor> { + const schema: HowTo = { + '@type': 'HowTo', + name: config.name, + description: config.description, + totalTime: config.totalTime, + step: config.steps.map((step) => + typeof step === 'string' + ? { '@type': 'HowToStep', text: step } + : { '@type': 'HowToStep', name: step.name, text: step.text, image: step.image }, + ), + } + + return create(schema) +} + +/** + * Creates a WebPage JSON-LD schema + * + * @example + * ```ts + * jsonLd.webpage({ + * name: 'About Us', + * description: 'Learn about our company', + * url: 'https://example.com/about', + * }) + * ``` + */ +function webpage(config: { + name: string + description?: string + url?: string + datePublished?: string + dateModified?: string + inLanguage?: string +}): Array<MetaDescriptor> { + const schema: WebPage = { + '@type': 'WebPage', + name: config.name, + description: config.description, + url: config.url, + datePublished: config.datePublished, + dateModified: config.dateModified, + inLanguage: config.inLanguage, + } + + return create(schema) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Namespace Export +// ───────────────────────────────────────────────────────────────────────────── + +/** + * JSON-LD builder namespace. + * + * Provides convenient methods for creating structured data for search engines. + * + * @example + * ```ts + * import { jsonLd } from '@tanstack/meta/json-ld' + * + * // In a route's head function + * head: () => ({ + * meta: [ + * ...createMeta({ title: 'Product', description: 'Great product' }), + * ...jsonLd.product({ + * name: 'Cool Widget', + * price: 99.99, + * currency: 'USD', + * }), + * ], + * }) + * ``` + */ +export const jsonLd = { + /** Create raw JSON-LD from any schema */ + create, + /** WebSite schema */ + website, + /** WebPage schema */ + webpage, + /** Organization schema */ + organization, + /** Person schema */ + person, + /** Article/BlogPosting schema */ + article, + /** Product schema */ + product, + /** BreadcrumbList schema */ + breadcrumbs, + /** FAQPage schema */ + faq, + /** Event schema */ + event, + /** LocalBusiness schema */ + localBusiness, + /** SoftwareApplication schema */ + softwareApp, + /** VideoObject schema */ + video, + /** Recipe schema */ + recipe, + /** Course schema */ + course, + /** HowTo schema */ + howTo, +} as const diff --git a/packages/meta/src/json-ld/index.ts b/packages/meta/src/json-ld/index.ts new file mode 100644 index 00000000000..c657baa814f --- /dev/null +++ b/packages/meta/src/json-ld/index.ts @@ -0,0 +1,27 @@ +/** + * @tanstack/meta/json-ld + * + * JSON-LD structured data builders for SEO and rich results. + * + * @example + * ```ts + * import { jsonLd } from '@tanstack/meta/json-ld' + * + * // Add structured data to a product page + * head: () => ({ + * meta: [ + * ...createMeta({ title: 'Product', description: 'Great product' }), + * ...jsonLd.product({ + * name: 'Cool Widget', + * price: 99.99, + * currency: 'USD', + * availability: 'InStock', + * }), + * ], + * }) + * ``` + */ + +export { jsonLd } from './builders' +export { JsonLd } from './types' +export type * from './types' diff --git a/packages/meta/src/json-ld/types.ts b/packages/meta/src/json-ld/types.ts new file mode 100644 index 00000000000..b92963fb020 --- /dev/null +++ b/packages/meta/src/json-ld/types.ts @@ -0,0 +1,370 @@ +/** + * JSON-LD Schema Types + * + * These types represent common Schema.org types used for structured data. + * Import types using the JsonLd namespace: + * + * @example + * ```ts + * import type { JsonLd } from '@tanstack/meta/json-ld' + * + * const product: JsonLd.Product = { ... } + * ``` + */ + +// ───────────────────────────────────────────────────────────────────────────── +// Base Types +// ───────────────────────────────────────────────────────────────────────────── + +/** Base properties shared by all Schema.org types */ +export interface Thing { + '@type': string + '@id'?: string + name?: string + description?: string + url?: string + image?: string | Image | Array<string | Image> + sameAs?: string | Array<string> +} + +export interface Image extends Thing { + '@type': 'ImageObject' + contentUrl?: string + width?: number | string + height?: number | string + caption?: string +} + +// ───────────────────────────────────────────────────────────────────────────── +// Organization Types +// ───────────────────────────────────────────────────────────────────────────── + +export interface Organization extends Thing { + '@type': 'Organization' + logo?: string | Image + email?: string + telephone?: string + address?: PostalAddress | string + contactPoint?: ContactPoint | Array<ContactPoint> +} + +export interface ContactPoint extends Thing { + '@type': 'ContactPoint' + telephone?: string + contactType?: string + email?: string + areaServed?: string | Array<string> +} + +export interface PostalAddress extends Thing { + '@type': 'PostalAddress' + streetAddress?: string + addressLocality?: string + addressRegion?: string + postalCode?: string + addressCountry?: string +} + +export interface Person extends Thing { + '@type': 'Person' + givenName?: string + familyName?: string + email?: string + telephone?: string + jobTitle?: string +} + +// ───────────────────────────────────────────────────────────────────────────── +// Website & Page Types +// ───────────────────────────────────────────────────────────────────────────── + +export interface WebSite extends Thing { + '@type': 'WebSite' + publisher?: Organization | Person + potentialAction?: SearchAction | Array<SearchAction> +} + +export interface SearchAction { + '@type': 'SearchAction' + target: string | { '@type': 'EntryPoint'; urlTemplate: string } + 'query-input'?: string +} + +export interface WebPage extends Thing { + '@type': 'WebPage' + breadcrumb?: BreadcrumbList + datePublished?: string + dateModified?: string + author?: Person | Organization + publisher?: Organization + inLanguage?: string +} + +// ───────────────────────────────────────────────────────────────────────────── +// Content Types +// ───────────────────────────────────────────────────────────────────────────── + +export interface Article extends Thing { + '@type': 'Article' | 'NewsArticle' | 'BlogPosting' | 'TechArticle' + headline?: string + datePublished?: string + dateModified?: string + author?: Person | Organization | Array<Person> + publisher?: Organization + articleSection?: string + wordCount?: number + keywords?: string | Array<string> + thumbnailUrl?: string +} + +export interface BreadcrumbList { + '@type': 'BreadcrumbList' + itemListElement: Array<ListItem> +} + +export interface ListItem { + '@type': 'ListItem' + position: number + name?: string + item?: string | Thing +} + +export interface FAQPage extends Thing { + '@type': 'FAQPage' + mainEntity: Array<Question> +} + +export interface Question extends Thing { + '@type': 'Question' + acceptedAnswer?: Answer +} + +export interface Answer extends Thing { + '@type': 'Answer' + text?: string +} + +export interface HowTo extends Thing { + '@type': 'HowTo' + step: Array<HowToStep> + totalTime?: string + estimatedCost?: string +} + +export interface HowToStep { + '@type': 'HowToStep' + name?: string + text?: string + url?: string + image?: string | Image +} + +// ───────────────────────────────────────────────────────────────────────────── +// Product & Commerce Types +// ───────────────────────────────────────────────────────────────────────────── + +export interface Product extends Thing { + '@type': 'Product' + brand?: Organization | { '@type': 'Brand'; name: string } + sku?: string + gtin?: string + mpn?: string + offers?: Offer | Array<Offer> + aggregateRating?: AggregateRating + review?: Review | Array<Review> +} + +export interface Offer extends Thing { + '@type': 'Offer' + price?: number | string + priceCurrency?: string + priceValidUntil?: string + availability?: + | 'https://schema.org/InStock' + | 'https://schema.org/OutOfStock' + | 'https://schema.org/PreOrder' + | string + itemCondition?: + | 'https://schema.org/NewCondition' + | 'https://schema.org/UsedCondition' + | string + seller?: Organization | Person +} + +export interface AggregateRating { + '@type': 'AggregateRating' + ratingValue: number | string + ratingCount?: number + reviewCount?: number + bestRating?: number | string + worstRating?: number | string +} + +export interface Review extends Thing { + '@type': 'Review' + author?: Person | Organization + datePublished?: string + reviewBody?: string + reviewRating?: { + '@type': 'Rating' + ratingValue: number | string + bestRating?: number | string + worstRating?: number | string + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Event Types +// ───────────────────────────────────────────────────────────────────────────── + +export interface Event extends Thing { + '@type': 'Event' | 'MusicEvent' | 'BusinessEvent' | string + startDate?: string + endDate?: string + location?: Place | VirtualLocation | string + organizer?: Organization | Person + performer?: Person | Organization | Array<Person> + offers?: Offer | Array<Offer> + eventStatus?: + | 'https://schema.org/EventScheduled' + | 'https://schema.org/EventCancelled' + | 'https://schema.org/EventPostponed' + | string + eventAttendanceMode?: + | 'https://schema.org/OfflineEventAttendanceMode' + | 'https://schema.org/OnlineEventAttendanceMode' + | 'https://schema.org/MixedEventAttendanceMode' + | string +} + +export interface Place extends Thing { + '@type': 'Place' + address?: PostalAddress | string + geo?: { '@type': 'GeoCoordinates'; latitude?: number; longitude?: number } +} + +export interface VirtualLocation { + '@type': 'VirtualLocation' + url?: string +} + +// ───────────────────────────────────────────────────────────────────────────── +// Business Types +// ───────────────────────────────────────────────────────────────────────────── + +export interface LocalBusiness extends Omit<Organization, '@type'> { + '@type': 'LocalBusiness' | 'Restaurant' | 'Store' | string + address?: PostalAddress | string + geo?: { '@type': 'GeoCoordinates'; latitude?: number; longitude?: number } + openingHours?: string | Array<string> + priceRange?: string + aggregateRating?: AggregateRating + review?: Review | Array<Review> +} + +// ───────────────────────────────────────────────────────────────────────────── +// Media Types +// ───────────────────────────────────────────────────────────────────────────── + +export interface Video extends Thing { + '@type': 'VideoObject' + contentUrl?: string + embedUrl?: string + uploadDate?: string + duration?: string + thumbnailUrl?: string | Array<string> + transcript?: string +} + +export interface SoftwareApplication extends Thing { + '@type': 'SoftwareApplication' | 'MobileApplication' | 'WebApplication' + applicationCategory?: string + operatingSystem?: string + offers?: Offer | Array<Offer> + aggregateRating?: AggregateRating + downloadUrl?: string + softwareVersion?: string +} + +// ───────────────────────────────────────────────────────────────────────────── +// Education Types +// ───────────────────────────────────────────────────────────────────────────── + +export interface Course extends Thing { + '@type': 'Course' + provider?: Organization + courseCode?: string + aggregateRating?: AggregateRating + offers?: Offer | Array<Offer> +} + +// ───────────────────────────────────────────────────────────────────────────── +// Recipe Type +// ───────────────────────────────────────────────────────────────────────────── + +export interface Recipe extends Thing { + '@type': 'Recipe' + author?: Person | Organization + datePublished?: string + prepTime?: string + cookTime?: string + totalTime?: string + recipeYield?: string + recipeIngredient?: Array<string> + recipeInstructions?: Array<HowToStep> | string + recipeCategory?: string + recipeCuisine?: string + aggregateRating?: AggregateRating + keywords?: string +} + +// ───────────────────────────────────────────────────────────────────────────── +// Namespace Export +// ───────────────────────────────────────────────────────────────────────────── + +/** + * JSON-LD type namespace + * + * @example + * ```ts + * import type { JsonLd } from '@tanstack/meta/json-ld' + * + * const product: JsonLd.Product = { + * '@type': 'Product', + * name: 'Cool Product', + * } + * ``` + */ +export namespace JsonLd { + export type { + Thing, + Image, + Organization, + ContactPoint, + PostalAddress, + Person, + WebSite, + SearchAction, + WebPage, + Article, + BreadcrumbList, + ListItem, + FAQPage, + Question, + Answer, + HowTo, + HowToStep, + Product, + Offer, + AggregateRating, + Review, + Event, + Place, + VirtualLocation, + LocalBusiness, + Video, + SoftwareApplication, + Course, + Recipe, + } +} diff --git a/packages/meta/src/merge.ts b/packages/meta/src/merge.ts new file mode 100644 index 00000000000..17a01f504d8 --- /dev/null +++ b/packages/meta/src/merge.ts @@ -0,0 +1,177 @@ +import type { MetaDescriptor } from './types' + +/** + * Merge strategy for handling duplicate meta tags + */ +export type MergeStrategy = 'last-wins' | 'first-wins' | 'append' + +/** + * Options for mergeMeta + */ +export interface MergeOptions { + /** + * How to handle duplicates + * - 'last-wins': Later values override earlier ones (default) + * - 'first-wins': Keep first occurrence + * - 'append': Keep all values, no deduplication + */ + strategy?: MergeStrategy +} + +/** + * Merges multiple meta arrays with intelligent deduplication. + * + * By default, later values override earlier ones for the same key. + * Keys are determined by: + * - title: 'title' + * - name: 'name:${name}' + * - property: 'property:${property}' + * - canonical link: 'link:canonical' + * - JSON-LD: 'jsonld:${@type}' + * + * @example + * ```ts + * import { createMeta, mergeMeta } from '@tanstack/meta' + * import { jsonLd } from '@tanstack/meta/json-ld' + * + * // Merge parent route meta with child route meta + * head: ({ matches }) => { + * const parentMeta = matches[0]?.meta ?? [] + * return { + * meta: mergeMeta( + * parentMeta, + * createMeta({ title: 'Child Page', description: 'Child desc' }), + * ), + * } + * } + * ``` + */ +export function mergeMeta( + ...sources: Array<Array<MetaDescriptor> | undefined | null> +): Array<MetaDescriptor> { + return mergeMetaWith({}, ...sources) +} + +/** + * Merges meta arrays with custom options + * + * @example + * ```ts + * // Keep first occurrence instead of last + * mergeMetaWith({ strategy: 'first-wins' }, baseMeta, overrideMeta) + * + * // Keep all values without deduplication + * mergeMetaWith({ strategy: 'append' }, meta1, meta2) + * ``` + */ +export function mergeMetaWith( + options: MergeOptions, + ...sources: Array<Array<MetaDescriptor> | undefined | null> +): Array<MetaDescriptor> { + const { strategy = 'last-wins' } = options + + if (strategy === 'append') { + return sources.filter(Boolean).flat() as Array<MetaDescriptor> + } + + const metaByKey = new Map<string, MetaDescriptor>() + const orderedKeys: Array<string> = [] + + for (const source of sources) { + if (!source) continue + + for (const descriptor of source) { + const key = getMetaKey(descriptor) + + if (strategy === 'first-wins' && metaByKey.has(key)) { + continue + } + + if (!metaByKey.has(key)) { + orderedKeys.push(key) + } + + metaByKey.set(key, descriptor) + } + } + + return orderedKeys.map((key) => metaByKey.get(key)!) +} + +/** + * Gets a unique key for a meta descriptor for deduplication + */ +function getMetaKey(descriptor: MetaDescriptor): string { + if ('charSet' in descriptor) return 'charset' + if ('title' in descriptor) return 'title' + if ('name' in descriptor && 'content' in descriptor) + return `name:${(descriptor as any).name}` + if ('property' in descriptor && 'content' in descriptor) + return `property:${(descriptor as any).property}` + if ('httpEquiv' in descriptor) return `http-equiv:${(descriptor as any).httpEquiv}` + if ('script:ld+json' in descriptor) { + const ldJson = (descriptor as any)['script:ld+json'] + if (ldJson['@id']) return `jsonld:${ldJson['@id']}` + if (ldJson['@type']) return `jsonld:${ldJson['@type']}` + if (ldJson['@graph']) return 'jsonld:graph' + return `jsonld:${JSON.stringify(ldJson)}` + } + if ('tagName' in descriptor) { + const tagName = (descriptor as any).tagName + if (tagName === 'link' && (descriptor as any).rel === 'canonical') + return 'link:canonical' + if (tagName === 'link' && (descriptor as any).rel === 'alternate') { + const hreflang = (descriptor as any).hreflang || '' + return `link:alternate:${hreflang}` + } + return `${tagName}:${JSON.stringify(descriptor)}` + } + + return JSON.stringify(descriptor) +} + +/** + * Removes specific meta tags from an array by their keys + * + * @example + * ```ts + * // Remove og:image from parent meta + * const filtered = excludeMeta(parentMeta, ['og:image', 'twitter:image']) + * ``` + */ +export function excludeMeta( + meta: Array<MetaDescriptor>, + keys: Array<string>, +): Array<MetaDescriptor> { + const keySet = new Set(keys.map(normalizeKey)) + return meta.filter((m) => !keySet.has(getMetaKey(m))) +} + +/** + * Picks specific meta tags from an array by their keys + * + * @example + * ```ts + * // Keep only title and description + * const picked = pickMeta(parentMeta, ['title', 'description']) + * ``` + */ +export function pickMeta( + meta: Array<MetaDescriptor>, + keys: Array<string>, +): Array<MetaDescriptor> { + const keySet = new Set(keys.map(normalizeKey)) + return meta.filter((m) => keySet.has(getMetaKey(m))) +} + +/** + * Normalizes a user-provided key to match our internal key format + */ +function normalizeKey(key: string): string { + if (key === 'title' || key === 'charset' || key === 'description') + return key === 'description' ? 'name:description' : key + if (key.startsWith('og:')) return `property:${key}` + if (key.startsWith('twitter:')) return `name:${key}` + if (key.startsWith('article:')) return `property:${key}` + return key +} diff --git a/packages/meta/src/types.ts b/packages/meta/src/types.ts new file mode 100644 index 00000000000..7c6cf505d16 --- /dev/null +++ b/packages/meta/src/types.ts @@ -0,0 +1,100 @@ +/** + * Meta descriptor type compatible with TanStack Router's head function. + * Each descriptor represents a single meta tag, link, or script element. + */ +export type MetaDescriptor = + | { charSet: 'utf-8' } + | { title: string } + | { name: string; content: string } + | { property: string; content: string } + | { httpEquiv: string; content: string } + | { 'script:ld+json': JsonLdObject } + | { tagName: 'meta' | 'link'; [name: string]: string } + | Record<string, unknown> + +type JsonLdObject = { [Key in string]: JsonLdValue } & { + [Key in string]?: JsonLdValue | undefined +} +type JsonLdArray = Array<JsonLdValue> | ReadonlyArray<JsonLdValue> +type JsonLdPrimitive = string | number | boolean | null +type JsonLdValue = JsonLdPrimitive | JsonLdObject | JsonLdArray + +/** + * Image configuration for Open Graph and Twitter cards + */ +export interface MetaImage { + /** Image URL */ + url: string + /** Image width in pixels */ + width?: number + /** Image height in pixels */ + height?: number + /** Alt text for accessibility */ + alt?: string +} + +/** + * Robot directives for search engine crawlers + */ +export interface RobotsConfig { + /** Allow indexing (default: true) */ + index?: boolean + /** Allow following links (default: true) */ + follow?: boolean + /** Prevent caching */ + noarchive?: boolean + /** Prevent snippets */ + nosnippet?: boolean + /** Maximum snippet length */ + maxSnippet?: number + /** Maximum image preview size */ + maxImagePreview?: 'none' | 'standard' | 'large' +} + +/** + * Open Graph configuration for social sharing + */ +export interface OpenGraphConfig { + /** Content title (defaults to main title) */ + title?: string + /** Content description (defaults to main description) */ + description?: string + /** Content type */ + type?: 'website' | 'article' | 'product' | 'profile' | string + /** Canonical URL */ + url?: string + /** Site name */ + siteName?: string + /** Locale (e.g., 'en_US') */ + locale?: string + /** Images */ + images?: Array<string | MetaImage> + /** Article-specific properties */ + article?: { + publishedTime?: string + modifiedTime?: string + authors?: Array<string> + section?: string + tags?: Array<string> + } +} + +/** + * Twitter Card configuration + */ +export interface TwitterConfig { + /** Card type */ + card?: 'summary' | 'summary_large_image' | 'app' | 'player' + /** @username of website */ + site?: string + /** @username of content creator */ + creator?: string + /** Title (defaults to main title) */ + title?: string + /** Description (defaults to main description) */ + description?: string + /** Image URL */ + image?: string + /** Image alt text */ + imageAlt?: string +} diff --git a/packages/meta/tests/builders.test.ts b/packages/meta/tests/builders.test.ts new file mode 100644 index 00000000000..4454a99155a --- /dev/null +++ b/packages/meta/tests/builders.test.ts @@ -0,0 +1,359 @@ +import { describe, expect, it } from 'vitest' +import { + meta, + title, + description, + charset, + viewport, + robots, + canonical, + alternate, + openGraph, + twitter, + themeColor, + verification, +} from '../src/builders' + +describe('Core Builders', () => { + describe('title', () => { + it('should create a title descriptor', () => { + const result = title('My Page') + expect(result).toEqual([{ title: 'My Page' }]) + }) + + it('should apply template to title', () => { + const result = title('My Page', '%s | My Site') + expect(result).toEqual([{ title: 'My Page | My Site' }]) + }) + }) + + describe('description', () => { + it('should create a description meta descriptor', () => { + const result = description('This is my page description') + expect(result).toEqual([ + { name: 'description', content: 'This is my page description' }, + ]) + }) + }) + + describe('charset', () => { + it('should create a charset descriptor with utf-8', () => { + const result = charset() + expect(result).toEqual([{ charSet: 'utf-8' }]) + }) + }) + + describe('viewport', () => { + it('should create a viewport descriptor with default value', () => { + const result = viewport() + expect(result).toEqual([ + { name: 'viewport', content: 'width=device-width, initial-scale=1' }, + ]) + }) + + it('should create a viewport descriptor with custom value', () => { + const result = viewport( + 'width=device-width, initial-scale=1, maximum-scale=5', + ) + expect(result).toEqual([ + { + name: 'viewport', + content: 'width=device-width, initial-scale=1, maximum-scale=5', + }, + ]) + }) + }) +}) + +describe('SEO Builders', () => { + describe('robots', () => { + it('should return empty array for empty config', () => { + const result = robots({}) + expect(result).toEqual([]) + }) + + it('should create index directive', () => { + const result = robots({ index: true }) + expect(result).toEqual([{ name: 'robots', content: 'index' }]) + }) + + it('should create noindex directive', () => { + const result = robots({ index: false }) + expect(result).toEqual([{ name: 'robots', content: 'noindex' }]) + }) + + it('should create follow directive', () => { + const result = robots({ follow: true }) + expect(result).toEqual([{ name: 'robots', content: 'follow' }]) + }) + + it('should create nofollow directive', () => { + const result = robots({ follow: false }) + expect(result).toEqual([{ name: 'robots', content: 'nofollow' }]) + }) + + it('should combine multiple directives', () => { + const result = robots({ + index: true, + follow: true, + noarchive: true, + maxSnippet: 160, + }) + expect(result).toEqual([ + { + name: 'robots', + content: 'index, follow, noarchive, max-snippet:160', + }, + ]) + }) + + it('should include maxImagePreview', () => { + const result = robots({ + maxImagePreview: 'large', + }) + expect(result).toEqual([ + { + name: 'robots', + content: 'max-image-preview:large', + }, + ]) + }) + }) + + describe('canonical', () => { + it('should create a canonical link descriptor', () => { + const result = canonical('https://example.com/page') + expect(result).toEqual([ + { tagName: 'link', rel: 'canonical', href: 'https://example.com/page' }, + ]) + }) + }) + + describe('alternate', () => { + it('should create alternate link descriptors', () => { + const result = alternate([ + { lang: 'en', href: 'https://example.com/en/page' }, + { lang: 'es', href: 'https://example.com/es/page' }, + { lang: 'x-default', href: 'https://example.com/page' }, + ]) + expect(result).toEqual([ + { + tagName: 'link', + rel: 'alternate', + hreflang: 'en', + href: 'https://example.com/en/page', + }, + { + tagName: 'link', + rel: 'alternate', + hreflang: 'es', + href: 'https://example.com/es/page', + }, + { + tagName: 'link', + rel: 'alternate', + hreflang: 'x-default', + href: 'https://example.com/page', + }, + ]) + }) + }) +}) + +describe('Open Graph Builder', () => { + it('should create basic Open Graph descriptors', () => { + const result = openGraph({ + title: 'My Page', + description: 'Page description', + type: 'website', + }) + expect(result).toEqual([ + { property: 'og:title', content: 'My Page' }, + { property: 'og:description', content: 'Page description' }, + { property: 'og:type', content: 'website' }, + ]) + }) + + it('should handle string images', () => { + const result = openGraph({ + image: 'https://example.com/image.jpg', + }) + expect(result).toEqual([ + { property: 'og:image', content: 'https://example.com/image.jpg' }, + ]) + }) + + it('should handle image objects with dimensions', () => { + const result = openGraph({ + image: { + url: 'https://example.com/og.jpg', + width: 1200, + height: 630, + alt: 'OG Image', + }, + }) + expect(result).toEqual([ + { property: 'og:image', content: 'https://example.com/og.jpg' }, + { property: 'og:image:width', content: '1200' }, + { property: 'og:image:height', content: '630' }, + { property: 'og:image:alt', content: 'OG Image' }, + ]) + }) + + it('should handle images array', () => { + const result = openGraph({ + images: [ + 'https://example.com/image1.jpg', + { url: 'https://example.com/image2.jpg', width: 800 }, + ], + }) + expect(result).toContainEqual({ + property: 'og:image', + content: 'https://example.com/image1.jpg', + }) + expect(result).toContainEqual({ + property: 'og:image', + content: 'https://example.com/image2.jpg', + }) + expect(result).toContainEqual({ + property: 'og:image:width', + content: '800', + }) + }) + + it('should include optional properties', () => { + const result = openGraph({ + url: 'https://example.com', + siteName: 'My Site', + locale: 'en_US', + }) + expect(result).toContainEqual({ + property: 'og:url', + content: 'https://example.com', + }) + expect(result).toContainEqual({ + property: 'og:site_name', + content: 'My Site', + }) + expect(result).toContainEqual({ + property: 'og:locale', + content: 'en_US', + }) + }) +}) + +describe('Twitter Builder', () => { + it('should create Twitter Card descriptors', () => { + const result = twitter({ + card: 'summary_large_image', + title: 'My Page', + description: 'Page description', + image: 'https://example.com/twitter.jpg', + site: '@mysite', + creator: '@author', + }) + expect(result).toEqual([ + { name: 'twitter:card', content: 'summary_large_image' }, + { name: 'twitter:title', content: 'My Page' }, + { name: 'twitter:description', content: 'Page description' }, + { name: 'twitter:image', content: 'https://example.com/twitter.jpg' }, + { name: 'twitter:site', content: '@mysite' }, + { name: 'twitter:creator', content: '@author' }, + ]) + }) + + it('should include image alt when provided', () => { + const result = twitter({ + image: 'https://example.com/image.jpg', + imageAlt: 'Description of image', + }) + expect(result).toEqual([ + { name: 'twitter:image', content: 'https://example.com/image.jpg' }, + { name: 'twitter:image:alt', content: 'Description of image' }, + ]) + }) + + it('should only include provided properties', () => { + const result = twitter({ + card: 'summary', + }) + expect(result).toEqual([{ name: 'twitter:card', content: 'summary' }]) + }) +}) + +describe('Utility Builders', () => { + describe('themeColor', () => { + it('should create a single theme-color meta tag', () => { + const result = themeColor('#ffffff') + expect(result).toEqual([{ name: 'theme-color', content: '#ffffff' }]) + }) + + it('should create theme-color meta tags for light and dark modes', () => { + const result = themeColor({ light: '#ffffff', dark: '#000000' }) + expect(result).toEqual([ + { + name: 'theme-color', + content: '#ffffff', + media: '(prefers-color-scheme: light)', + }, + { + name: 'theme-color', + content: '#000000', + media: '(prefers-color-scheme: dark)', + }, + ]) + }) + }) + + describe('verification', () => { + it('should create verification meta tags', () => { + const result = verification({ + google: 'google-code', + bing: 'bing-code', + yandex: 'yandex-code', + pinterest: 'pinterest-code', + }) + expect(result).toEqual([ + { name: 'google-site-verification', content: 'google-code' }, + { name: 'msvalidate.01', content: 'bing-code' }, + { name: 'yandex-verification', content: 'yandex-code' }, + { name: 'p:domain_verify', content: 'pinterest-code' }, + ]) + }) + + it('should only include provided verification codes', () => { + const result = verification({ google: 'google-code' }) + expect(result).toEqual([ + { name: 'google-site-verification', content: 'google-code' }, + ]) + }) + }) +}) + +describe('meta namespace', () => { + it('should export all builders through namespace', () => { + expect(meta.title).toBe(title) + expect(meta.description).toBe(description) + expect(meta.charset).toBe(charset) + expect(meta.viewport).toBe(viewport) + expect(meta.robots).toBe(robots) + expect(meta.canonical).toBe(canonical) + expect(meta.alternate).toBe(alternate) + expect(meta.openGraph).toBe(openGraph) + expect(meta.twitter).toBe(twitter) + expect(meta.themeColor).toBe(themeColor) + expect(meta.verification).toBe(verification) + }) + + it('should allow composing via namespace', () => { + const result = [ + ...meta.charset(), + ...meta.viewport(), + ...meta.title('My Page'), + ...meta.description('Description'), + ] + expect(result).toHaveLength(4) + expect(result).toContainEqual({ charSet: 'utf-8' }) + expect(result).toContainEqual({ title: 'My Page' }) + }) +}) diff --git a/packages/meta/tests/createMeta.test.ts b/packages/meta/tests/createMeta.test.ts new file mode 100644 index 00000000000..5291c4fdac8 --- /dev/null +++ b/packages/meta/tests/createMeta.test.ts @@ -0,0 +1,439 @@ +import { describe, expect, it } from 'vitest' +import { createMeta } from '../src/createMeta' + +describe('createMeta', () => { + describe('basic usage', () => { + it('should create essential meta tags with minimal config', () => { + const result = createMeta({ + title: 'My Page', + description: 'A great page', + }) + + // Should include charset + expect(result).toContainEqual({ charSet: 'utf-8' }) + + // Should include viewport + expect(result).toContainEqual({ + name: 'viewport', + content: 'width=device-width, initial-scale=1', + }) + + // Should include title + expect(result).toContainEqual({ title: 'My Page' }) + + // Should include description + expect(result).toContainEqual({ + name: 'description', + content: 'A great page', + }) + + // Should include Open Graph basics + expect(result).toContainEqual({ + property: 'og:title', + content: 'My Page', + }) + expect(result).toContainEqual({ + property: 'og:description', + content: 'A great page', + }) + expect(result).toContainEqual({ + property: 'og:type', + content: 'website', + }) + + // Should include Twitter Card + expect(result).toContainEqual({ + name: 'twitter:card', + content: 'summary', + }) + expect(result).toContainEqual({ + name: 'twitter:title', + content: 'My Page', + }) + expect(result).toContainEqual({ + name: 'twitter:description', + content: 'A great page', + }) + }) + + it('should create full meta tags with url and image', () => { + const result = createMeta({ + title: 'My Page', + description: 'A great page', + url: 'https://example.com/page', + image: 'https://example.com/og.jpg', + }) + + // Should include canonical + expect(result).toContainEqual({ + tagName: 'link', + rel: 'canonical', + href: 'https://example.com/page', + }) + + // Should include OG image + expect(result).toContainEqual({ + property: 'og:image', + content: 'https://example.com/og.jpg', + }) + + // Should include OG url + expect(result).toContainEqual({ + property: 'og:url', + content: 'https://example.com/page', + }) + + // Should use summary_large_image when image is present + expect(result).toContainEqual({ + name: 'twitter:card', + content: 'summary_large_image', + }) + + // Should include twitter image + expect(result).toContainEqual({ + name: 'twitter:image', + content: 'https://example.com/og.jpg', + }) + }) + }) + + describe('title template', () => { + it('should apply title template', () => { + const result = createMeta({ + title: 'About', + description: 'About us', + titleTemplate: '%s | My Site', + }) + + expect(result).toContainEqual({ title: 'About | My Site' }) + }) + }) + + describe('image with dimensions', () => { + it('should include image dimensions when provided', () => { + const result = createMeta({ + title: 'Page', + description: 'Description', + image: { + url: 'https://example.com/og.jpg', + width: 1200, + height: 630, + alt: 'Alt text', + }, + }) + + expect(result).toContainEqual({ + property: 'og:image', + content: 'https://example.com/og.jpg', + }) + expect(result).toContainEqual({ + property: 'og:image:width', + content: '1200', + }) + expect(result).toContainEqual({ + property: 'og:image:height', + content: '630', + }) + expect(result).toContainEqual({ + property: 'og:image:alt', + content: 'Alt text', + }) + expect(result).toContainEqual({ + name: 'twitter:image:alt', + content: 'Alt text', + }) + }) + }) + + describe('optional properties', () => { + it('should include siteName when provided', () => { + const result = createMeta({ + title: 'Page', + description: 'Desc', + siteName: 'My Site', + }) + + expect(result).toContainEqual({ + property: 'og:site_name', + content: 'My Site', + }) + }) + + it('should include locale when provided', () => { + const result = createMeta({ + title: 'Page', + description: 'Desc', + locale: 'en_US', + }) + + expect(result).toContainEqual({ + property: 'og:locale', + content: 'en_US', + }) + }) + + it('should include Twitter site/creator handles', () => { + const result = createMeta({ + title: 'Page', + description: 'Desc', + twitterSite: '@mysite', + twitterCreator: '@author', + }) + + expect(result).toContainEqual({ + name: 'twitter:site', + content: '@mysite', + }) + expect(result).toContainEqual({ + name: 'twitter:creator', + content: '@author', + }) + }) + }) + + describe('robots', () => { + it('should include robots directive when provided', () => { + const result = createMeta({ + title: 'Page', + description: 'Desc', + robots: { index: false, follow: true, noarchive: true }, + }) + + expect(result).toContainEqual({ + name: 'robots', + content: 'noindex, follow, noarchive', + }) + }) + + it('should handle maxSnippet and maxImagePreview', () => { + const result = createMeta({ + title: 'Page', + description: 'Desc', + robots: { maxSnippet: 160, maxImagePreview: 'large' }, + }) + + expect(result).toContainEqual({ + name: 'robots', + content: 'max-snippet:160, max-image-preview:large', + }) + }) + }) + + describe('theme color', () => { + it('should include single theme color', () => { + const result = createMeta({ + title: 'Page', + description: 'Desc', + themeColor: '#ffffff', + }) + + expect(result).toContainEqual({ + name: 'theme-color', + content: '#ffffff', + }) + }) + + it('should include light/dark theme colors', () => { + const result = createMeta({ + title: 'Page', + description: 'Desc', + themeColor: { light: '#ffffff', dark: '#000000' }, + }) + + expect(result).toContainEqual({ + name: 'theme-color', + content: '#ffffff', + media: '(prefers-color-scheme: light)', + }) + expect(result).toContainEqual({ + name: 'theme-color', + content: '#000000', + media: '(prefers-color-scheme: dark)', + }) + }) + }) + + describe('type', () => { + it('should default to website type', () => { + const result = createMeta({ + title: 'Page', + description: 'Desc', + }) + + expect(result).toContainEqual({ + property: 'og:type', + content: 'website', + }) + }) + + it('should use custom type when provided', () => { + const result = createMeta({ + title: 'Page', + description: 'Desc', + type: 'article', + }) + + expect(result).toContainEqual({ + property: 'og:type', + content: 'article', + }) + }) + }) + + describe('Open Graph overrides', () => { + it('should override inferred values with openGraph config', () => { + const result = createMeta({ + title: 'Page Title', + description: 'Page Desc', + openGraph: { + title: 'OG Title', + description: 'OG Desc', + }, + }) + + expect(result).toContainEqual({ + property: 'og:title', + content: 'OG Title', + }) + expect(result).toContainEqual({ + property: 'og:description', + content: 'OG Desc', + }) + }) + + it('should include article properties', () => { + const result = createMeta({ + title: 'Article', + description: 'Desc', + type: 'article', + openGraph: { + article: { + publishedTime: '2024-01-15T08:00:00+00:00', + modifiedTime: '2024-01-16T10:00:00+00:00', + section: 'Technology', + authors: ['John Doe'], + tags: ['tech', 'news'], + }, + }, + }) + + expect(result).toContainEqual({ + property: 'article:published_time', + content: '2024-01-15T08:00:00+00:00', + }) + expect(result).toContainEqual({ + property: 'article:modified_time', + content: '2024-01-16T10:00:00+00:00', + }) + expect(result).toContainEqual({ + property: 'article:section', + content: 'Technology', + }) + expect(result).toContainEqual({ + property: 'article:author', + content: 'John Doe', + }) + expect(result).toContainEqual({ + property: 'article:tag', + content: 'tech', + }) + }) + }) + + describe('Twitter overrides', () => { + it('should override inferred values with twitter config', () => { + const result = createMeta({ + title: 'Page Title', + description: 'Page Desc', + twitter: { + title: 'Twitter Title', + card: 'app', + }, + }) + + expect(result).toContainEqual({ + name: 'twitter:title', + content: 'Twitter Title', + }) + expect(result).toContainEqual({ + name: 'twitter:card', + content: 'app', + }) + }) + }) + + describe('control flags', () => { + it('should omit charset when charset=false', () => { + const result = createMeta({ + title: 'Page', + description: 'Desc', + charset: false, + }) + + expect(result).not.toContainEqual({ charSet: 'utf-8' }) + }) + + it('should omit viewport when viewport=false', () => { + const result = createMeta({ + title: 'Page', + description: 'Desc', + viewport: false, + }) + + expect(result).not.toContainEqual({ + name: 'viewport', + content: 'width=device-width, initial-scale=1', + }) + }) + + it('should use custom viewport when string provided', () => { + const result = createMeta({ + title: 'Page', + description: 'Desc', + viewport: 'width=device-width, initial-scale=1, maximum-scale=5', + }) + + expect(result).toContainEqual({ + name: 'viewport', + content: 'width=device-width, initial-scale=1, maximum-scale=5', + }) + }) + + it('should omit canonical when canonical=false', () => { + const result = createMeta({ + title: 'Page', + description: 'Desc', + url: 'https://example.com', + canonical: false, + }) + + expect(result).not.toContainEqual({ + tagName: 'link', + rel: 'canonical', + href: 'https://example.com', + }) + }) + }) + + describe('extend', () => { + it('should append extended meta descriptors', () => { + const result = createMeta({ + title: 'Page', + description: 'Desc', + extend: [ + { name: 'author', content: 'John Doe' }, + { name: 'keywords', content: 'a, b, c' }, + ], + }) + + expect(result).toContainEqual({ + name: 'author', + content: 'John Doe', + }) + expect(result).toContainEqual({ + name: 'keywords', + content: 'a, b, c', + }) + }) + }) +}) diff --git a/packages/meta/tests/json-ld.test.ts b/packages/meta/tests/json-ld.test.ts new file mode 100644 index 00000000000..0d002c99290 --- /dev/null +++ b/packages/meta/tests/json-ld.test.ts @@ -0,0 +1,438 @@ +import { describe, expect, it } from 'vitest' +import { jsonLd } from '../src/json-ld/builders' + +describe('JSON-LD Builders', () => { + describe('jsonLd.create', () => { + it('should create a JSON-LD descriptor with single schema', () => { + const result = jsonLd.create({ + '@type': 'WebSite', + name: 'My Site', + url: 'https://example.com', + }) + expect(result).toEqual([ + { + 'script:ld+json': { + '@context': 'https://schema.org', + '@type': 'WebSite', + name: 'My Site', + url: 'https://example.com', + }, + }, + ]) + }) + + it('should create a JSON-LD descriptor with @graph for multiple schemas', () => { + const result = jsonLd.create([ + { '@type': 'WebSite', name: 'My Site' }, + { '@type': 'Organization', name: 'My Org' }, + ]) + expect(result).toEqual([ + { + 'script:ld+json': { + '@context': 'https://schema.org', + '@graph': [ + { '@type': 'WebSite', name: 'My Site' }, + { '@type': 'Organization', name: 'My Org' }, + ], + }, + }, + ]) + }) + }) + + describe('jsonLd.website', () => { + it('should create a WebSite schema', () => { + const result = jsonLd.website({ + name: 'My Site', + url: 'https://example.com', + }) + const schema = (result[0] as any)['script:ld+json'] + expect(schema['@context']).toBe('https://schema.org') + expect(schema['@type']).toBe('WebSite') + expect(schema.name).toBe('My Site') + expect(schema.url).toBe('https://example.com') + }) + + it('should include search action when searchUrl provided', () => { + const result = jsonLd.website({ + name: 'My Site', + searchUrl: 'https://example.com/search?q={query}', + }) + const schema = (result[0] as any)['script:ld+json'] + expect(schema.potentialAction).toBeDefined() + expect(schema.potentialAction['@type']).toBe('SearchAction') + }) + }) + + describe('jsonLd.organization', () => { + it('should create an Organization schema', () => { + const result = jsonLd.organization({ + name: 'My Company', + url: 'https://example.com', + logo: 'https://example.com/logo.png', + socials: [ + 'https://twitter.com/mycompany', + 'https://linkedin.com/company/mycompany', + ], + }) + const schema = (result[0] as any)['script:ld+json'] + expect(schema['@type']).toBe('Organization') + expect(schema.name).toBe('My Company') + expect(schema.logo).toBe('https://example.com/logo.png') + expect(schema.sameAs).toEqual([ + 'https://twitter.com/mycompany', + 'https://linkedin.com/company/mycompany', + ]) + }) + + it('should include address when provided', () => { + const result = jsonLd.organization({ + name: 'My Company', + address: { + street: '123 Main St', + city: 'City', + region: 'State', + postal: '12345', + country: 'US', + }, + }) + const schema = (result[0] as any)['script:ld+json'] + expect(schema.address['@type']).toBe('PostalAddress') + expect(schema.address.streetAddress).toBe('123 Main St') + }) + }) + + describe('jsonLd.person', () => { + it('should create a Person schema', () => { + const result = jsonLd.person({ + name: 'John Doe', + url: 'https://johndoe.com', + jobTitle: 'Software Engineer', + }) + const schema = (result[0] as any)['script:ld+json'] + expect(schema['@type']).toBe('Person') + expect(schema.name).toBe('John Doe') + expect(schema.jobTitle).toBe('Software Engineer') + }) + }) + + describe('jsonLd.article', () => { + it('should create an Article schema', () => { + const result = jsonLd.article({ + headline: 'Article Title', + description: 'Article description', + author: 'John Doe', + datePublished: '2024-01-15', + }) + const schema = (result[0] as any)['script:ld+json'] + expect(schema['@type']).toBe('Article') + expect(schema.headline).toBe('Article Title') + expect(schema.author['@type']).toBe('Person') + expect(schema.author.name).toBe('John Doe') + }) + + it('should handle author object', () => { + const result = jsonLd.article({ + headline: 'Article Title', + author: { name: 'John Doe', url: 'https://johndoe.com' }, + }) + const schema = (result[0] as any)['script:ld+json'] + expect(schema.author.name).toBe('John Doe') + expect(schema.author.url).toBe('https://johndoe.com') + }) + + it('should allow custom article type', () => { + const result = jsonLd.article({ + headline: 'Blog Post', + type: 'BlogPosting', + }) + const schema = (result[0] as any)['script:ld+json'] + expect(schema['@type']).toBe('BlogPosting') + }) + }) + + describe('jsonLd.product', () => { + it('should create a Product schema', () => { + const result = jsonLd.product({ + name: 'Cool Product', + description: 'A very cool product', + price: 99.99, + currency: 'USD', + availability: 'InStock', + brand: 'My Brand', + }) + const schema = (result[0] as any)['script:ld+json'] + expect(schema['@type']).toBe('Product') + expect(schema.name).toBe('Cool Product') + expect(schema.brand['@type']).toBe('Brand') + expect(schema.brand.name).toBe('My Brand') + expect(schema.offers.price).toBe(99.99) + expect(schema.offers.availability).toBe('https://schema.org/InStock') + }) + + it('should include rating when provided', () => { + const result = jsonLd.product({ + name: 'Product', + rating: { value: 4.5, count: 100 }, + }) + const schema = (result[0] as any)['script:ld+json'] + expect(schema.aggregateRating['@type']).toBe('AggregateRating') + expect(schema.aggregateRating.ratingValue).toBe(4.5) + expect(schema.aggregateRating.ratingCount).toBe(100) + }) + }) + + describe('jsonLd.breadcrumbs', () => { + it('should create a BreadcrumbList schema', () => { + const result = jsonLd.breadcrumbs([ + { name: 'Home', url: 'https://example.com' }, + { name: 'Category', url: 'https://example.com/category' }, + { name: 'Product', url: 'https://example.com/category/product' }, + ]) + const schema = (result[0] as any)['script:ld+json'] + expect(schema['@type']).toBe('BreadcrumbList') + expect(schema.itemListElement).toHaveLength(3) + expect(schema.itemListElement[0].position).toBe(1) + expect(schema.itemListElement[0].name).toBe('Home') + expect(schema.itemListElement[2].position).toBe(3) + }) + }) + + describe('jsonLd.faq', () => { + it('should create a FAQPage schema', () => { + const result = jsonLd.faq([ + { question: 'What is X?', answer: 'X is...' }, + { question: 'How do I Y?', answer: 'You can Y by...' }, + ]) + const schema = (result[0] as any)['script:ld+json'] + expect(schema['@type']).toBe('FAQPage') + expect(schema.mainEntity).toHaveLength(2) + expect(schema.mainEntity[0]['@type']).toBe('Question') + expect(schema.mainEntity[0].name).toBe('What is X?') + expect(schema.mainEntity[0].acceptedAnswer['@type']).toBe('Answer') + expect(schema.mainEntity[0].acceptedAnswer.text).toBe('X is...') + }) + }) + + describe('jsonLd.event', () => { + it('should create an Event schema', () => { + const result = jsonLd.event({ + name: 'Concert', + startDate: '2024-06-15T19:00:00-07:00', + location: 'Madison Square Garden', + }) + const schema = (result[0] as any)['script:ld+json'] + expect(schema['@type']).toBe('Event') + expect(schema.name).toBe('Concert') + expect(schema.location).toBe('Madison Square Garden') + }) + + it('should handle location object', () => { + const result = jsonLd.event({ + name: 'Concert', + location: { name: 'Madison Square Garden', address: 'NYC' }, + }) + const schema = (result[0] as any)['script:ld+json'] + expect(schema.location['@type']).toBe('Place') + expect(schema.location.name).toBe('Madison Square Garden') + }) + + it('should handle virtual location', () => { + const result = jsonLd.event({ + name: 'Webinar', + location: { name: 'Online', url: 'https://example.com/webinar' }, + }) + const schema = (result[0] as any)['script:ld+json'] + expect(schema.location['@type']).toBe('VirtualLocation') + }) + + it('should allow custom event type', () => { + const result = jsonLd.event({ + name: 'Concert', + type: 'MusicEvent', + }) + const schema = (result[0] as any)['script:ld+json'] + expect(schema['@type']).toBe('MusicEvent') + }) + }) + + describe('jsonLd.localBusiness', () => { + it('should create a LocalBusiness schema', () => { + const result = jsonLd.localBusiness({ + name: 'My Restaurant', + type: 'Restaurant', + telephone: '+1-555-555-5555', + priceRange: '$$', + }) + const schema = (result[0] as any)['script:ld+json'] + expect(schema['@type']).toBe('Restaurant') + expect(schema.telephone).toBe('+1-555-555-5555') + expect(schema.priceRange).toBe('$$') + }) + + it('should handle string address', () => { + const result = jsonLd.localBusiness({ + name: 'My Business', + address: '123 Main St, City, State 12345', + }) + const schema = (result[0] as any)['script:ld+json'] + expect(schema.address).toBe('123 Main St, City, State 12345') + }) + + it('should handle address object', () => { + const result = jsonLd.localBusiness({ + name: 'My Business', + address: { street: '123 Main St', city: 'City' }, + }) + const schema = (result[0] as any)['script:ld+json'] + expect(schema.address['@type']).toBe('PostalAddress') + }) + }) + + describe('jsonLd.softwareApp', () => { + it('should create a SoftwareApplication schema', () => { + const result = jsonLd.softwareApp({ + name: 'My App', + type: 'MobileApplication', + operatingSystem: 'iOS, Android', + category: 'GameApplication', + price: 0, + }) + const schema = (result[0] as any)['script:ld+json'] + expect(schema['@type']).toBe('MobileApplication') + expect(schema.applicationCategory).toBe('GameApplication') + expect(schema.offers.price).toBe(0) + }) + }) + + describe('jsonLd.video', () => { + it('should create a VideoObject schema', () => { + const result = jsonLd.video({ + name: 'Video Title', + description: 'Video description', + thumbnail: 'https://example.com/thumb.jpg', + uploadDate: '2024-01-15', + duration: 'PT5M30S', + }) + const schema = (result[0] as any)['script:ld+json'] + expect(schema['@type']).toBe('VideoObject') + expect(schema.name).toBe('Video Title') + expect(schema.thumbnailUrl).toBe('https://example.com/thumb.jpg') + expect(schema.duration).toBe('PT5M30S') + }) + }) + + describe('jsonLd.recipe', () => { + it('should create a Recipe schema', () => { + const result = jsonLd.recipe({ + name: 'Chocolate Cake', + description: 'Delicious cake', + author: 'Chef John', + prepTime: 'PT30M', + cookTime: 'PT1H', + servings: '8 servings', + ingredients: ['2 cups flour', '1 cup sugar'], + }) + const schema = (result[0] as any)['script:ld+json'] + expect(schema['@type']).toBe('Recipe') + expect(schema.name).toBe('Chocolate Cake') + expect(schema.author['@type']).toBe('Person') + expect(schema.recipeIngredient).toHaveLength(2) + }) + + it('should handle string instructions', () => { + const result = jsonLd.recipe({ + name: 'Simple Recipe', + instructions: 'Mix and bake', + }) + const schema = (result[0] as any)['script:ld+json'] + expect(schema.recipeInstructions).toBe('Mix and bake') + }) + + it('should handle array instructions', () => { + const result = jsonLd.recipe({ + name: 'Simple Recipe', + instructions: ['Mix ingredients', 'Bake for 30 minutes'], + }) + const schema = (result[0] as any)['script:ld+json'] + expect(schema.recipeInstructions).toHaveLength(2) + expect(schema.recipeInstructions[0]['@type']).toBe('HowToStep') + }) + }) + + describe('jsonLd.course', () => { + it('should create a Course schema', () => { + const result = jsonLd.course({ + name: 'Introduction to Programming', + description: 'Learn the basics', + provider: 'Online Academy', + }) + const schema = (result[0] as any)['script:ld+json'] + expect(schema['@type']).toBe('Course') + expect(schema.provider['@type']).toBe('Organization') + expect(schema.provider.name).toBe('Online Academy') + }) + }) + + describe('jsonLd.howTo', () => { + it('should create a HowTo schema with string steps', () => { + const result = jsonLd.howTo({ + name: 'How to Make Coffee', + steps: ['Boil water', 'Add coffee grounds', 'Pour water'], + }) + const schema = (result[0] as any)['script:ld+json'] + expect(schema['@type']).toBe('HowTo') + expect(schema.step).toHaveLength(3) + expect(schema.step[0]['@type']).toBe('HowToStep') + expect(schema.step[0].text).toBe('Boil water') + }) + + it('should handle object steps', () => { + const result = jsonLd.howTo({ + name: 'How to Make Coffee', + steps: [ + { name: 'Step 1', text: 'Boil water', image: 'https://example.com/step1.jpg' }, + ], + }) + const schema = (result[0] as any)['script:ld+json'] + expect(schema.step[0].name).toBe('Step 1') + expect(schema.step[0].image).toBe('https://example.com/step1.jpg') + }) + }) + + describe('jsonLd.webpage', () => { + it('should create a WebPage schema', () => { + const result = jsonLd.webpage({ + name: 'About Us', + description: 'Learn about our company', + url: 'https://example.com/about', + inLanguage: 'en', + }) + const schema = (result[0] as any)['script:ld+json'] + expect(schema['@type']).toBe('WebPage') + expect(schema.name).toBe('About Us') + expect(schema.inLanguage).toBe('en') + }) + }) +}) + +describe('jsonLd namespace', () => { + it('should export all builders', () => { + expect(typeof jsonLd.create).toBe('function') + expect(typeof jsonLd.website).toBe('function') + expect(typeof jsonLd.organization).toBe('function') + expect(typeof jsonLd.person).toBe('function') + expect(typeof jsonLd.article).toBe('function') + expect(typeof jsonLd.product).toBe('function') + expect(typeof jsonLd.breadcrumbs).toBe('function') + expect(typeof jsonLd.faq).toBe('function') + expect(typeof jsonLd.event).toBe('function') + expect(typeof jsonLd.localBusiness).toBe('function') + expect(typeof jsonLd.softwareApp).toBe('function') + expect(typeof jsonLd.video).toBe('function') + expect(typeof jsonLd.recipe).toBe('function') + expect(typeof jsonLd.course).toBe('function') + expect(typeof jsonLd.howTo).toBe('function') + expect(typeof jsonLd.webpage).toBe('function') + }) +}) diff --git a/packages/meta/tests/merge.test.ts b/packages/meta/tests/merge.test.ts new file mode 100644 index 00000000000..979b120ca5f --- /dev/null +++ b/packages/meta/tests/merge.test.ts @@ -0,0 +1,243 @@ +import { describe, expect, it } from 'vitest' +import { mergeMeta, mergeMetaWith, excludeMeta, pickMeta } from '../src/merge' +import { title, description, charset, canonical, openGraph, twitter } from '../src/builders' + +describe('mergeMeta', () => { + it('should merge multiple meta arrays', () => { + const result = mergeMeta( + title('Page Title'), + description('Description'), + charset(), + ) + expect(result).toHaveLength(3) + }) + + it('should deduplicate by title', () => { + const result = mergeMeta(title('Title 1'), title('Title 2')) + expect(result).toHaveLength(1) + expect(result[0]).toEqual({ title: 'Title 2' }) + }) + + it('should deduplicate by name attribute', () => { + const result = mergeMeta( + description('Description 1'), + description('Description 2'), + ) + expect(result).toHaveLength(1) + expect(result[0]).toEqual({ + name: 'description', + content: 'Description 2', + }) + }) + + it('should deduplicate by property attribute', () => { + const result = mergeMeta( + openGraph({ title: 'Title 1' }), + openGraph({ title: 'Title 2' }), + ) + expect(result).toHaveLength(1) + expect(result[0]).toEqual({ property: 'og:title', content: 'Title 2' }) + }) + + it('should preserve order with later values winning', () => { + const result = mergeMeta( + [ + { title: 'Title 1' }, + { name: 'description', content: 'Desc 1' }, + ], + [ + { name: 'description', content: 'Desc 2' }, + { name: 'keywords', content: 'a, b, c' }, + ], + ) + expect(result).toEqual([ + { title: 'Title 1' }, + { name: 'description', content: 'Desc 2' }, + { name: 'keywords', content: 'a, b, c' }, + ]) + }) + + it('should handle null and undefined sources', () => { + const result = mergeMeta(title('Title'), null, undefined, description('Desc')) + expect(result).toHaveLength(2) + }) + + it('should deduplicate charset', () => { + const result = mergeMeta(charset(), charset()) + expect(result).toHaveLength(1) + }) + + it('should deduplicate canonical links', () => { + const result = mergeMeta( + canonical('https://example.com/1'), + canonical('https://example.com/2'), + ) + expect(result).toHaveLength(1) + expect(result[0]).toEqual({ + tagName: 'link', + rel: 'canonical', + href: 'https://example.com/2', + }) + }) + + it('should deduplicate JSON-LD by @type', () => { + const result = mergeMeta( + [{ 'script:ld+json': { '@context': 'https://schema.org', '@type': 'WebSite', name: 'Site 1' } }], + [{ 'script:ld+json': { '@context': 'https://schema.org', '@type': 'WebSite', name: 'Site 2' } }], + ) + expect(result).toHaveLength(1) + expect((result[0] as any)['script:ld+json'].name).toBe('Site 2') + }) + + it('should not deduplicate different JSON-LD types', () => { + const result = mergeMeta( + [{ 'script:ld+json': { '@context': 'https://schema.org', '@type': 'WebSite', name: 'Site' } }], + [{ 'script:ld+json': { '@context': 'https://schema.org', '@type': 'Organization', name: 'Org' } }], + ) + expect(result).toHaveLength(2) + }) + + it('should deduplicate JSON-LD by @id when present', () => { + const result = mergeMeta( + [{ 'script:ld+json': { '@context': 'https://schema.org', '@type': 'WebSite', '@id': 'site', name: 'Site 1' } }], + [{ 'script:ld+json': { '@context': 'https://schema.org', '@type': 'WebSite', '@id': 'site', name: 'Site 2' } }], + ) + expect(result).toHaveLength(1) + expect((result[0] as any)['script:ld+json'].name).toBe('Site 2') + }) +}) + +describe('mergeMetaWith', () => { + it('should use first-wins strategy', () => { + const result = mergeMetaWith( + { strategy: 'first-wins' }, + title('Title 1'), + title('Title 2'), + ) + expect(result).toHaveLength(1) + expect(result[0]).toEqual({ title: 'Title 1' }) + }) + + it('should use append strategy (no deduplication)', () => { + const result = mergeMetaWith( + { strategy: 'append' }, + title('Title 1'), + title('Title 2'), + ) + expect(result).toHaveLength(2) + expect(result[0]).toEqual({ title: 'Title 1' }) + expect(result[1]).toEqual({ title: 'Title 2' }) + }) + + it('should use last-wins strategy by default', () => { + const result = mergeMetaWith( + {}, + title('Title 1'), + title('Title 2'), + ) + expect(result).toHaveLength(1) + expect(result[0]).toEqual({ title: 'Title 2' }) + }) +}) + +describe('excludeMeta', () => { + it('should exclude meta by key', () => { + const meta = [ + { title: 'Title' }, + { name: 'description', content: 'Desc' }, + { property: 'og:title', content: 'OG Title' }, + ] + const result = excludeMeta(meta, ['title', 'og:title']) + expect(result).toHaveLength(1) + expect(result[0]).toEqual({ name: 'description', content: 'Desc' }) + }) + + it('should handle twitter keys', () => { + const meta = [ + { name: 'twitter:card', content: 'summary' }, + { name: 'twitter:title', content: 'Title' }, + { name: 'description', content: 'Desc' }, + ] + const result = excludeMeta(meta, ['twitter:card']) + expect(result).toHaveLength(2) + expect(result).not.toContainEqual({ + name: 'twitter:card', + content: 'summary', + }) + }) + + it('should handle description key normalization', () => { + const meta = [ + { title: 'Title' }, + { name: 'description', content: 'Desc' }, + ] + const result = excludeMeta(meta, ['description']) + expect(result).toHaveLength(1) + expect(result[0]).toEqual({ title: 'Title' }) + }) +}) + +describe('pickMeta', () => { + it('should pick meta by key', () => { + const meta = [ + { title: 'Title' }, + { name: 'description', content: 'Desc' }, + { property: 'og:title', content: 'OG Title' }, + { property: 'og:image', content: 'https://example.com/img.jpg' }, + ] + const result = pickMeta(meta, ['title', 'og:title']) + expect(result).toHaveLength(2) + expect(result).toContainEqual({ title: 'Title' }) + expect(result).toContainEqual({ + property: 'og:title', + content: 'OG Title', + }) + }) + + it('should pick description with normalized key', () => { + const meta = [ + { title: 'Title' }, + { name: 'description', content: 'Desc' }, + ] + const result = pickMeta(meta, ['description']) + expect(result).toHaveLength(1) + expect(result[0]).toEqual({ name: 'description', content: 'Desc' }) + }) + + it('should pick twitter keys', () => { + const meta = [ + { name: 'twitter:card', content: 'summary' }, + { name: 'twitter:title', content: 'Title' }, + { name: 'description', content: 'Desc' }, + ] + const result = pickMeta(meta, ['twitter:card', 'twitter:title']) + expect(result).toHaveLength(2) + }) +}) + +describe('Composability', () => { + it('should allow spreading multiple helpers into meta array', () => { + const meta = [ + ...charset(), + ...title('My Page'), + ...description('Description'), + ...openGraph({ title: 'My Page', type: 'website' }), + ...twitter({ card: 'summary' }), + ] + expect(meta.length).toBeGreaterThan(5) + }) + + it('should compose JSON-LD with regular meta', () => { + const regularMeta = [ + ...title('Product Page'), + ...description('A great product'), + ] + const jsonLdMeta = [ + { 'script:ld+json': { '@context': 'https://schema.org', '@type': 'Product', name: 'Great Product' } }, + ] + const result = mergeMeta(regularMeta, jsonLdMeta) + + expect(result).toContainEqual({ title: 'Product Page' }) + expect(result.some((m) => 'script:ld+json' in m)).toBe(true) + }) +}) diff --git a/packages/meta/tsconfig.json b/packages/meta/tsconfig.json new file mode 100644 index 00000000000..9b21ee123bc --- /dev/null +++ b/packages/meta/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src", "vite.config.ts", "tests"] +} diff --git a/packages/meta/vite.config.ts b/packages/meta/vite.config.ts new file mode 100644 index 00000000000..1dabb6bab87 --- /dev/null +++ b/packages/meta/vite.config.ts @@ -0,0 +1,21 @@ +import { defineConfig, mergeConfig } from 'vitest/config' +import { tanstackViteConfig } from '@tanstack/config/vite' +import packageJson from './package.json' + +const config = defineConfig({ + test: { + name: packageJson.name, + dir: './tests', + watch: false, + environment: 'jsdom', + typecheck: { enabled: true }, + }, +}) + +export default mergeConfig( + config, + tanstackViteConfig({ + entry: ['./src/index.ts', './src/json-ld/index.ts'], + srcDir: './src', + }), +) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3ab9c9061b6..3a6476f3216 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11233,6 +11233,12 @@ importers: packages/history: {} + packages/meta: + devDependencies: + esbuild: + specifier: ^0.25.0 + version: 0.25.10 + packages/nitro-v2-vite-plugin: dependencies: nitropack: @@ -11460,6 +11466,9 @@ importers: '@tanstack/history': specifier: workspace:* version: link:../history + '@tanstack/meta': + specifier: workspace:* + version: link:../meta '@tanstack/store': specifier: ^0.8.0 version: 0.8.0 @@ -20961,9 +20970,6 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true - loupe@3.1.3: - resolution: {integrity: sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==} - loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} @@ -29200,11 +29206,11 @@ snapshots: '@types/eslint-scope@3.7.7': dependencies: '@types/eslint': 9.6.1 - '@types/estree': 1.0.7 + '@types/estree': 1.0.8 '@types/eslint@9.6.1': dependencies: - '@types/estree': 1.0.7 + '@types/estree': 1.0.8 '@types/json-schema': 7.0.15 '@types/estree@1.0.7': {} @@ -29885,7 +29891,7 @@ snapshots: '@vitest/utils@3.0.6': dependencies: '@vitest/pretty-format': 3.0.6 - loupe: 3.1.3 + loupe: 3.2.1 tinyrainbow: 2.0.0 '@vitest/utils@3.2.4': @@ -30816,7 +30822,7 @@ snapshots: assertion-error: 2.0.1 check-error: 2.1.1 deep-eql: 5.0.2 - loupe: 3.1.3 + loupe: 3.2.1 pathval: 2.0.0 chalk@3.0.0: @@ -32167,7 +32173,7 @@ snapshots: estree-walker@3.0.3: dependencies: - '@types/estree': 1.0.7 + '@types/estree': 1.0.8 esutils@2.0.3: {} @@ -33126,7 +33132,7 @@ snapshots: is-reference@1.2.1: dependencies: - '@types/estree': 1.0.7 + '@types/estree': 1.0.8 is-regex@1.2.1: dependencies: @@ -33624,8 +33630,6 @@ snapshots: dependencies: js-tokens: 4.0.0 - loupe@3.1.3: {} - loupe@3.2.1: {} lower-case@2.0.2: