From 105c454d7312f756e2e33a76aff975e8b755271a Mon Sep 17 00:00:00 2001 From: rasm-its Date: Mon, 28 Sep 2026 19:44:28 +0200 Subject: [PATCH] Added OG support --- dist/modules/seo/buildMetadata.d.ts | 17 +++++++++++- dist/modules/seo/buildMetadata.js | 24 ++++++++++++++--- dist/modules/seo/buildMetadata.js.map | 2 +- docs/seo.md | 25 ++++++++++++++++++ src/modules/seo/buildMetadata.ts | 37 ++++++++++++++++++++++++--- 5 files changed, 95 insertions(+), 10 deletions(-) diff --git a/dist/modules/seo/buildMetadata.d.ts b/dist/modules/seo/buildMetadata.d.ts index 514e222..dc45277 100644 --- a/dist/modules/seo/buildMetadata.d.ts +++ b/dist/modules/seo/buildMetadata.d.ts @@ -17,12 +17,27 @@ export type PageMetadata = { url: string; }[]; locale?: string; + /** Site/brand name for the OG card. */ + siteName?: string; title: string; + /** 'website' | 'article' etc. Defaults to 'website'. */ + type?: string; + /** Canonical URL of this page. */ + url?: string; }; - /** robots directives — set to noindex/follow for legal/thin/search pages. */ + /** + * robots directives. Indexed pages get generous snippet/preview limits by + * default (Google shows richer results); noindex pages get index:false. + */ robots?: { follow: boolean; index: boolean; + /** 'none' | 'standard' | 'large' — image preview size in results. */ + 'max-image-preview'?: 'large' | 'none' | 'standard'; + /** Max text snippet length; -1 = no limit. Next maps to max-snippet. */ + 'max-snippet'?: number; + /** Max video preview seconds; -1 = no limit. */ + 'max-video-preview'?: number; }; title: string; }; diff --git a/dist/modules/seo/buildMetadata.js b/dist/modules/seo/buildMetadata.js index 508d55b..7738aba 100644 --- a/dist/modules/seo/buildMetadata.js +++ b/dist/modules/seo/buildMetadata.js @@ -71,16 +71,32 @@ import { buildHreflangAlternates } from './hreflang.js'; ...images && { images }, - locale + type: 'website', + locale, + ...siteName ? { + siteName + } : {}, + ...canonical ? { + url: canonical + } : {} }, - // noindex → tell search engines to exclude the page but still follow links - // (authority flows through). For legal/thin/search-result pages. + // robots: noindex pages are excluded (follow keeps link authority). Indexed + // pages get generous snippet/preview limits so Google can show rich results + // (long snippets, large image previews, full video previews). ...meta?.noindex ? { robots: { follow: true, index: false } - } : {} + } : { + robots: { + follow: true, + index: true, + 'max-image-preview': 'large', + 'max-snippet': -1, + 'max-video-preview': -1 + } + } }; } diff --git a/dist/modules/seo/buildMetadata.js.map b/dist/modules/seo/buildMetadata.js.map index 0307984..8c24baa 100644 --- a/dist/modules/seo/buildMetadata.js.map +++ b/dist/modules/seo/buildMetadata.js.map @@ -1 +1 @@ -{"version":3,"sources":["../../../src/modules/seo/buildMetadata.ts"],"sourcesContent":["import type { I18nConfig, LocalizedSlugs } from '../i18n/index.js'\nimport type { TitleOrder } from './composeTitle.js'\nimport type { SeoMeta } from './types.js'\n\nimport { buildLocalizedPath } from '../i18n/index.js'\nimport { composeTitle } from './composeTitle.js'\nimport { buildHreflangAlternates } from './hreflang.js'\n\n/**\n * Subset of Next.js `Metadata` this helper produces. Kept local so the plugin\n * doesn't depend on `next` types; the shape is assignable to Next's Metadata.\n */\nexport type PageMetadata = {\n alternates?: {\n canonical?: string\n languages?: Record\n }\n description?: string\n openGraph?: {\n description?: string\n images?: { url: string }[]\n locale?: string\n title: string\n }\n /** robots directives — set to noindex/follow for legal/thin/search pages. */\n robots?: {\n follow: boolean\n index: boolean\n }\n title: string\n}\n\ntype BuildMetadataArgs = {\n /** Absolute site origin, e.g. 'https://example.com'. */\n baseUrl?: string\n config: I18nConfig\n /** Home slug that collapses to the locale root. Defaults to 'home'. */\n homeSlug?: Record | string\n /** Resolved OG image URL (page image or site defaultShareImage). */\n imageUrl?: null | string\n /** Current locale being rendered. */\n locale: string\n /** SEO meta from the document (plugin-seo group). */\n meta?: null | SeoMeta\n /** Page title or site name first. Defaults to 'page-first'. */\n order?: TitleOrder\n /**\n * The document's own title (e.g. page.title = 'Sprzątanie biur'). Used as the\n * page-title source when meta.title is empty — the browser tab and search\n * result should show the page name, not go blank, when an editor didn't fill\n * the SEO title. Priority: titleOverride > meta.title > pageTitle.\n */\n pageTitle?: null | string\n /**\n * Localized segment the document lives under (an archive page's slugs).\n * Feeds both canonical and hreflang, so /pl/artykuly/moj-post and\n * /en/articles/my-post point at each other correctly.\n */\n prefix?: LocalizedSlugs\n /**\n * Query string appended to canonical and every hreflang, e.g. '?page=2'.\n *\n * A paginated listing must be canonical to itself — pointing page 2 at page 1\n * tells Google the entries on it don't exist. Alternates carry the same page,\n * since /pl/artykuly?page=2 corresponds to /en/articles?page=2.\n */\n query?: string\n /** Separator between page title and site name. Defaults to ' | '. */\n separator?: string\n /**\n * Global fallback description (from SiteSettings.siteDescription). Used when a\n * page has no meta.description — better than emitting no description at all\n * (Google would then scrape random on-page text for the snippet).\n */\n siteDescription?: null | string\n /** Site name for title composition and OG. */\n siteName?: null | string\n /** slug per locale for this document — drives canonical + hreflang. */\n slugs: LocalizedSlugs\n}\n\n/**\n * Assembles a Next.js-compatible Metadata object from document SEO fields and\n * site-level data. Locale-aware: canonical points at the current locale's\n * path, and hreflang alternates cover every locale the document exists in.\n *\n * Designed for use inside Next.js `generateMetadata`. The caller resolves the\n * pieces (meta group, site name, image URL, localized slugs) and passes them\n * in — the plugin composes, it doesn't fetch.\n */\nexport function buildMetadata({\n baseUrl,\n config,\n homeSlug = 'home',\n imageUrl,\n locale,\n meta,\n order,\n pageTitle,\n prefix,\n query,\n separator,\n siteDescription,\n siteName,\n slugs,\n}: BuildMetadataArgs): PageMetadata {\n // Title source priority: titleOverride (exact, wins outright) > meta.title\n // (SEO title an editor set) > pageTitle (the document's own name). This means\n // a page with no SEO title still shows its name (e.g. 'Sprzątanie biur')\n // composed with the site name, instead of just the site name or a blank.\n const override = meta?.titleOverride?.trim()\n const resolvedPageTitle = meta?.title?.trim() || pageTitle?.trim() || undefined\n const title =\n override || composeTitle({ order, pageTitle: resolvedPageTitle, separator, siteName })\n const description = meta?.description?.trim() || siteDescription?.trim() || undefined\n const origin = baseUrl?.replace(/\\/$/, '') ?? ''\n\n const suffix = query ?? ''\n\n const currentPath = buildLocalizedPath({ config, homeSlug, locale, prefix, slugs })\n const canonical = currentPath ? `${origin}${currentPath}${suffix}` : undefined\n\n const languages = buildHreflangAlternates({ baseUrl, config, homeSlug, prefix, slugs })\n if (suffix) {\n for (const code of Object.keys(languages)) {\n languages[code] = `${languages[code]}${suffix}`\n }\n }\n\n const images = imageUrl ? [{ url: imageUrl }] : undefined\n\n return {\n title,\n ...(description && { description }),\n alternates: {\n ...(canonical && { canonical }),\n ...(Object.keys(languages).length > 0 && { languages }),\n },\n openGraph: {\n title,\n ...(description && { description }),\n ...(images && { images }),\n locale,\n },\n // noindex → tell search engines to exclude the page but still follow links\n // (authority flows through). For legal/thin/search-result pages.\n ...(meta?.noindex ? { robots: { follow: true, index: false } } : {}),\n }\n}\n"],"names":["buildLocalizedPath","composeTitle","buildHreflangAlternates","buildMetadata","baseUrl","config","homeSlug","imageUrl","locale","meta","order","pageTitle","prefix","query","separator","siteDescription","siteName","slugs","override","titleOverride","trim","resolvedPageTitle","title","undefined","description","origin","replace","suffix","currentPath","canonical","languages","code","Object","keys","images","url","alternates","length","openGraph","noindex","robots","follow","index"],"mappings":"AAIA,SAASA,kBAAkB,QAAQ,mBAAkB;AACrD,SAASC,YAAY,QAAQ,oBAAmB;AAChD,SAASC,uBAAuB,QAAQ,gBAAe;AA2EvD;;;;;;;;CAQC,GACD,OAAO,SAASC,cAAc,EAC5BC,OAAO,EACPC,MAAM,EACNC,WAAW,MAAM,EACjBC,QAAQ,EACRC,MAAM,EACNC,IAAI,EACJC,KAAK,EACLC,SAAS,EACTC,MAAM,EACNC,KAAK,EACLC,SAAS,EACTC,eAAe,EACfC,QAAQ,EACRC,KAAK,EACa;IAClB,2EAA2E;IAC3E,8EAA8E;IAC9E,yEAAyE;IACzE,yEAAyE;IACzE,MAAMC,WAAWT,MAAMU,eAAeC;IACtC,MAAMC,oBAAoBZ,MAAMa,OAAOF,UAAUT,WAAWS,UAAUG;IACtE,MAAMD,QACJJ,YAAYjB,aAAa;QAAES;QAAOC,WAAWU;QAAmBP;QAAWE;IAAS;IACtF,MAAMQ,cAAcf,MAAMe,aAAaJ,UAAUL,iBAAiBK,UAAUG;IAC5E,MAAME,SAASrB,SAASsB,QAAQ,OAAO,OAAO;IAE9C,MAAMC,SAASd,SAAS;IAExB,MAAMe,cAAc5B,mBAAmB;QAAEK;QAAQC;QAAUE;QAAQI;QAAQK;IAAM;IACjF,MAAMY,YAAYD,cAAc,GAAGH,SAASG,cAAcD,QAAQ,GAAGJ;IAErE,MAAMO,YAAY5B,wBAAwB;QAAEE;QAASC;QAAQC;QAAUM;QAAQK;IAAM;IACrF,IAAIU,QAAQ;QACV,KAAK,MAAMI,QAAQC,OAAOC,IAAI,CAACH,WAAY;YACzCA,SAAS,CAACC,KAAK,GAAG,GAAGD,SAAS,CAACC,KAAK,GAAGJ,QAAQ;QACjD;IACF;IAEA,MAAMO,SAAS3B,WAAW;QAAC;YAAE4B,KAAK5B;QAAS;KAAE,GAAGgB;IAEhD,OAAO;QACLD;QACA,GAAIE,eAAe;YAAEA;QAAY,CAAC;QAClCY,YAAY;YACV,GAAIP,aAAa;gBAAEA;YAAU,CAAC;YAC9B,GAAIG,OAAOC,IAAI,CAACH,WAAWO,MAAM,GAAG,KAAK;gBAAEP;YAAU,CAAC;QACxD;QACAQ,WAAW;YACThB;YACA,GAAIE,eAAe;gBAAEA;YAAY,CAAC;YAClC,GAAIU,UAAU;gBAAEA;YAAO,CAAC;YACxB1B;QACF;QACA,2EAA2E;QAC3E,iEAAiE;QACjE,GAAIC,MAAM8B,UAAU;YAAEC,QAAQ;gBAAEC,QAAQ;gBAAMC,OAAO;YAAM;QAAE,IAAI,CAAC,CAAC;IACrE;AACF"} \ No newline at end of file +{"version":3,"sources":["../../../src/modules/seo/buildMetadata.ts"],"sourcesContent":["import type { I18nConfig, LocalizedSlugs } from '../i18n/index.js'\nimport type { TitleOrder } from './composeTitle.js'\nimport type { SeoMeta } from './types.js'\n\nimport { buildLocalizedPath } from '../i18n/index.js'\nimport { composeTitle } from './composeTitle.js'\nimport { buildHreflangAlternates } from './hreflang.js'\n\n/**\n * Subset of Next.js `Metadata` this helper produces. Kept local so the plugin\n * doesn't depend on `next` types; the shape is assignable to Next's Metadata.\n */\nexport type PageMetadata = {\n alternates?: {\n canonical?: string\n languages?: Record\n }\n description?: string\n openGraph?: {\n description?: string\n images?: { url: string }[]\n locale?: string\n /** Site/brand name for the OG card. */\n siteName?: string\n title: string\n /** 'website' | 'article' etc. Defaults to 'website'. */\n type?: string\n /** Canonical URL of this page. */\n url?: string\n }\n /**\n * robots directives. Indexed pages get generous snippet/preview limits by\n * default (Google shows richer results); noindex pages get index:false.\n */\n robots?: {\n follow: boolean\n index: boolean\n /** 'none' | 'standard' | 'large' — image preview size in results. */\n 'max-image-preview'?: 'large' | 'none' | 'standard'\n /** Max text snippet length; -1 = no limit. Next maps to max-snippet. */\n 'max-snippet'?: number\n /** Max video preview seconds; -1 = no limit. */\n 'max-video-preview'?: number\n }\n title: string\n}\n\ntype BuildMetadataArgs = {\n /** Absolute site origin, e.g. 'https://example.com'. */\n baseUrl?: string\n config: I18nConfig\n /** Home slug that collapses to the locale root. Defaults to 'home'. */\n homeSlug?: Record | string\n /** Resolved OG image URL (page image or site defaultShareImage). */\n imageUrl?: null | string\n /** Current locale being rendered. */\n locale: string\n /** SEO meta from the document (plugin-seo group). */\n meta?: null | SeoMeta\n /** Page title or site name first. Defaults to 'page-first'. */\n order?: TitleOrder\n /**\n * The document's own title (e.g. page.title = 'Sprzątanie biur'). Used as the\n * page-title source when meta.title is empty — the browser tab and search\n * result should show the page name, not go blank, when an editor didn't fill\n * the SEO title. Priority: titleOverride > meta.title > pageTitle.\n */\n pageTitle?: null | string\n /**\n * Localized segment the document lives under (an archive page's slugs).\n * Feeds both canonical and hreflang, so /pl/artykuly/moj-post and\n * /en/articles/my-post point at each other correctly.\n */\n prefix?: LocalizedSlugs\n /**\n * Query string appended to canonical and every hreflang, e.g. '?page=2'.\n *\n * A paginated listing must be canonical to itself — pointing page 2 at page 1\n * tells Google the entries on it don't exist. Alternates carry the same page,\n * since /pl/artykuly?page=2 corresponds to /en/articles?page=2.\n */\n query?: string\n /** Separator between page title and site name. Defaults to ' | '. */\n separator?: string\n /**\n * Global fallback description (from SiteSettings.siteDescription). Used when a\n * page has no meta.description — better than emitting no description at all\n * (Google would then scrape random on-page text for the snippet).\n */\n siteDescription?: null | string\n /** Site name for title composition and OG. */\n siteName?: null | string\n /** slug per locale for this document — drives canonical + hreflang. */\n slugs: LocalizedSlugs\n}\n\n/**\n * Assembles a Next.js-compatible Metadata object from document SEO fields and\n * site-level data. Locale-aware: canonical points at the current locale's\n * path, and hreflang alternates cover every locale the document exists in.\n *\n * Designed for use inside Next.js `generateMetadata`. The caller resolves the\n * pieces (meta group, site name, image URL, localized slugs) and passes them\n * in — the plugin composes, it doesn't fetch.\n */\nexport function buildMetadata({\n baseUrl,\n config,\n homeSlug = 'home',\n imageUrl,\n locale,\n meta,\n order,\n pageTitle,\n prefix,\n query,\n separator,\n siteDescription,\n siteName,\n slugs,\n}: BuildMetadataArgs): PageMetadata {\n // Title source priority: titleOverride (exact, wins outright) > meta.title\n // (SEO title an editor set) > pageTitle (the document's own name). This means\n // a page with no SEO title still shows its name (e.g. 'Sprzątanie biur')\n // composed with the site name, instead of just the site name or a blank.\n const override = meta?.titleOverride?.trim()\n const resolvedPageTitle = meta?.title?.trim() || pageTitle?.trim() || undefined\n const title =\n override || composeTitle({ order, pageTitle: resolvedPageTitle, separator, siteName })\n const description = meta?.description?.trim() || siteDescription?.trim() || undefined\n const origin = baseUrl?.replace(/\\/$/, '') ?? ''\n\n const suffix = query ?? ''\n\n const currentPath = buildLocalizedPath({ config, homeSlug, locale, prefix, slugs })\n const canonical = currentPath ? `${origin}${currentPath}${suffix}` : undefined\n\n const languages = buildHreflangAlternates({ baseUrl, config, homeSlug, prefix, slugs })\n if (suffix) {\n for (const code of Object.keys(languages)) {\n languages[code] = `${languages[code]}${suffix}`\n }\n }\n\n const images = imageUrl ? [{ url: imageUrl }] : undefined\n\n return {\n title,\n ...(description && { description }),\n alternates: {\n ...(canonical && { canonical }),\n ...(Object.keys(languages).length > 0 && { languages }),\n },\n openGraph: {\n title,\n ...(description && { description }),\n ...(images && { images }),\n type: 'website',\n locale,\n ...(siteName ? { siteName } : {}),\n ...(canonical ? { url: canonical } : {}),\n },\n // robots: noindex pages are excluded (follow keeps link authority). Indexed\n // pages get generous snippet/preview limits so Google can show rich results\n // (long snippets, large image previews, full video previews).\n ...(meta?.noindex\n ? { robots: { follow: true, index: false } }\n : {\n robots: {\n follow: true,\n index: true,\n 'max-image-preview': 'large' as const,\n 'max-snippet': -1,\n 'max-video-preview': -1,\n },\n }),\n }\n}\n"],"names":["buildLocalizedPath","composeTitle","buildHreflangAlternates","buildMetadata","baseUrl","config","homeSlug","imageUrl","locale","meta","order","pageTitle","prefix","query","separator","siteDescription","siteName","slugs","override","titleOverride","trim","resolvedPageTitle","title","undefined","description","origin","replace","suffix","currentPath","canonical","languages","code","Object","keys","images","url","alternates","length","openGraph","type","noindex","robots","follow","index"],"mappings":"AAIA,SAASA,kBAAkB,QAAQ,mBAAkB;AACrD,SAASC,YAAY,QAAQ,oBAAmB;AAChD,SAASC,uBAAuB,QAAQ,gBAAe;AA0FvD;;;;;;;;CAQC,GACD,OAAO,SAASC,cAAc,EAC5BC,OAAO,EACPC,MAAM,EACNC,WAAW,MAAM,EACjBC,QAAQ,EACRC,MAAM,EACNC,IAAI,EACJC,KAAK,EACLC,SAAS,EACTC,MAAM,EACNC,KAAK,EACLC,SAAS,EACTC,eAAe,EACfC,QAAQ,EACRC,KAAK,EACa;IAClB,2EAA2E;IAC3E,8EAA8E;IAC9E,yEAAyE;IACzE,yEAAyE;IACzE,MAAMC,WAAWT,MAAMU,eAAeC;IACtC,MAAMC,oBAAoBZ,MAAMa,OAAOF,UAAUT,WAAWS,UAAUG;IACtE,MAAMD,QACJJ,YAAYjB,aAAa;QAAES;QAAOC,WAAWU;QAAmBP;QAAWE;IAAS;IACtF,MAAMQ,cAAcf,MAAMe,aAAaJ,UAAUL,iBAAiBK,UAAUG;IAC5E,MAAME,SAASrB,SAASsB,QAAQ,OAAO,OAAO;IAE9C,MAAMC,SAASd,SAAS;IAExB,MAAMe,cAAc5B,mBAAmB;QAAEK;QAAQC;QAAUE;QAAQI;QAAQK;IAAM;IACjF,MAAMY,YAAYD,cAAc,GAAGH,SAASG,cAAcD,QAAQ,GAAGJ;IAErE,MAAMO,YAAY5B,wBAAwB;QAAEE;QAASC;QAAQC;QAAUM;QAAQK;IAAM;IACrF,IAAIU,QAAQ;QACV,KAAK,MAAMI,QAAQC,OAAOC,IAAI,CAACH,WAAY;YACzCA,SAAS,CAACC,KAAK,GAAG,GAAGD,SAAS,CAACC,KAAK,GAAGJ,QAAQ;QACjD;IACF;IAEA,MAAMO,SAAS3B,WAAW;QAAC;YAAE4B,KAAK5B;QAAS;KAAE,GAAGgB;IAEhD,OAAO;QACLD;QACA,GAAIE,eAAe;YAAEA;QAAY,CAAC;QAClCY,YAAY;YACV,GAAIP,aAAa;gBAAEA;YAAU,CAAC;YAC9B,GAAIG,OAAOC,IAAI,CAACH,WAAWO,MAAM,GAAG,KAAK;gBAAEP;YAAU,CAAC;QACxD;QACAQ,WAAW;YACThB;YACA,GAAIE,eAAe;gBAAEA;YAAY,CAAC;YAClC,GAAIU,UAAU;gBAAEA;YAAO,CAAC;YACxBK,MAAM;YACN/B;YACA,GAAIQ,WAAW;gBAAEA;YAAS,IAAI,CAAC,CAAC;YAChC,GAAIa,YAAY;gBAAEM,KAAKN;YAAU,IAAI,CAAC,CAAC;QACzC;QACA,4EAA4E;QAC5E,4EAA4E;QAC5E,8DAA8D;QAC9D,GAAIpB,MAAM+B,UACN;YAAEC,QAAQ;gBAAEC,QAAQ;gBAAMC,OAAO;YAAM;QAAE,IACzC;YACEF,QAAQ;gBACNC,QAAQ;gBACRC,OAAO;gBACP,qBAAqB;gBACrB,eAAe,CAAC;gBAChB,qBAAqB,CAAC;YACxB;QACF,CAAC;IACP;AACF"} \ No newline at end of file diff --git a/docs/seo.md b/docs/seo.md index d183976..36b6f13 100644 --- a/docs/seo.md +++ b/docs/seo.md @@ -843,6 +843,31 @@ const jsonLd = buildArticleJsonLd({ Dane z dokumentu/panelu. Google wymaga headline + dat dla rich result. +## Robots — rich results (max-snippet, image/video preview) + +Strony indeksowane dostają automatycznie dyrektywy pozwalające Google na bogate +wyniki (buildMetadata generuje): +- `max-snippet: -1` — pełnej długości snippet (bez limitu) +- `max-image-preview: large` — duży podgląd obrazu w wynikach +- `max-video-preview: -1` — pełny podgląd wideo + +Strony noindex (polityki) dostają `index: false, follow: true` — bez powyższych. +Zero konfiguracji — działa automatycznie dla każdej strony przez createPageMetadata. + +## Open Graph — pełne pola (type, siteName, url, description) + +buildMetadata generuje kompletny OG dla social share (Facebook, LinkedIn, Slack): +- `og:title` — tytuł strony +- `og:description` — meta.description → fallback siteDescription (dziedziczy) +- `og:image` — defaultShareImage / obraz strony +- `og:type` — 'website' +- `og:site_name` — siteName z panelu +- `og:url` — canonical strony +- `og:locale` — język strony + +Wszystko z panelu, automatycznie. Opis OG dziedziczy z meta.description (albo +globalnego siteDescription gdy pusty) — patrz niżej. + ## Fallback meta description (siteDescription) Strona bez `meta.description` → plugin używa globalnego `siteDescription` z diff --git a/src/modules/seo/buildMetadata.ts b/src/modules/seo/buildMetadata.ts index 30bd8bf..c7d0e28 100644 --- a/src/modules/seo/buildMetadata.ts +++ b/src/modules/seo/buildMetadata.ts @@ -20,12 +20,27 @@ export type PageMetadata = { description?: string images?: { url: string }[] locale?: string + /** Site/brand name for the OG card. */ + siteName?: string title: string + /** 'website' | 'article' etc. Defaults to 'website'. */ + type?: string + /** Canonical URL of this page. */ + url?: string } - /** robots directives — set to noindex/follow for legal/thin/search pages. */ + /** + * robots directives. Indexed pages get generous snippet/preview limits by + * default (Google shows richer results); noindex pages get index:false. + */ robots?: { follow: boolean index: boolean + /** 'none' | 'standard' | 'large' — image preview size in results. */ + 'max-image-preview'?: 'large' | 'none' | 'standard' + /** Max text snippet length; -1 = no limit. Next maps to max-snippet. */ + 'max-snippet'?: number + /** Max video preview seconds; -1 = no limit. */ + 'max-video-preview'?: number } title: string } @@ -140,10 +155,24 @@ export function buildMetadata({ title, ...(description && { description }), ...(images && { images }), + type: 'website', locale, + ...(siteName ? { siteName } : {}), + ...(canonical ? { url: canonical } : {}), }, - // noindex → tell search engines to exclude the page but still follow links - // (authority flows through). For legal/thin/search-result pages. - ...(meta?.noindex ? { robots: { follow: true, index: false } } : {}), + // robots: noindex pages are excluded (follow keeps link authority). Indexed + // pages get generous snippet/preview limits so Google can show rich results + // (long snippets, large image previews, full video previews). + ...(meta?.noindex + ? { robots: { follow: true, index: false } } + : { + robots: { + follow: true, + index: true, + 'max-image-preview': 'large' as const, + 'max-snippet': -1, + 'max-video-preview': -1, + }, + }), } }