diff --git a/dist/index.d.ts b/dist/index.d.ts
index b41d7e1..3d4b2b0 100644
--- a/dist/index.d.ts
+++ b/dist/index.d.ts
@@ -30,6 +30,7 @@ export { getGlobal, getSiteIntegrations, getSiteSettings, SITE_INTEGRATIONS_SLUG
export { buildSecurityHeaders } from './modules/security/index.js';
export type { BuildSecurityHeadersArgs, SecurityHeader } from './modules/security/index.js';
export { buildIconsMetadata, buildOrganizationJsonLd, validateFaviconField, } from './modules/seo/index.js';
+export { buildBreadcrumbJsonLd, buildSiteNavigationJsonLd, buildWebSiteJsonLd, } from './modules/seo/index.js';
export type { PageMetadata, SeoMeta, SeoOption } from './modules/seo/index.js';
export { buildHreflangAlternates, buildMetadata, composeTitle } from './modules/seo/index.js';
export type { AutoFillMapping, RobotsRules, SitemapEntry } from './modules/seo/index.js';
diff --git a/dist/index.js b/dist/index.js
index 29d5f2f..8245f36 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -19,6 +19,9 @@ export { ALL_SYSTEM_PAGE_ROLES, getSystemPagePath } from './modules/pages/index.
export { getGlobal, getSiteIntegrations, getSiteSettings, SITE_INTEGRATIONS_SLUG, SITE_SETTINGS_SLUG } from './modules/payload/index.js';
export { buildSecurityHeaders } from './modules/security/index.js';
export { buildIconsMetadata, buildOrganizationJsonLd, validateFaviconField } from './modules/seo/index.js';
+// Structured data (schema.org JSON-LD) — brand/sitelink signals for Google.
+// WebSite (+ optional SearchAction), BreadcrumbList (per page), SiteNavigation.
+export { buildBreadcrumbJsonLd, buildSiteNavigationJsonLd, buildWebSiteJsonLd } from './modules/seo/index.js';
export { buildHreflangAlternates, buildMetadata, composeTitle } from './modules/seo/index.js';
export { buildAutoFillMetaHook, buildRobots, buildSitemapEntries, createMetadataGenerator, createPageMetadata, injectAutoFillMeta } from './modules/seo/index.js';
export { buildSlugField, toSlug } from './modules/slug/index.js';
diff --git a/dist/index.js.map b/dist/index.js.map
index 6c2bc83..b8741f1 100644
--- a/dist/index.js.map
+++ b/dist/index.js.map
@@ -1 +1 @@
-{"version":3,"sources":["../src/index.ts"],"sourcesContent":["export type { AccessOption, Role } from './modules/access/index.js'\nexport {\n adminOnly,\n adminOnlyField,\n adminOrEditor,\n adminOrEditorField,\n adminOrSelf,\n authenticated,\n hasMinimumRole,\n isAdmin,\n isEditor,\n requireRole,\n requireRoleField,\n ROLE_HIERARCHY,\n} from './modules/access/index.js'\nexport type { AnalyticsConfig } from './modules/analytics/index.js'\nexport { getAnalyticsConfig } from './modules/analytics/index.js'\nexport {\n ACCEPT_ALL_CONSENT,\n CONSENT_CATEGORIES,\n CONSENT_COOKIE,\n CONSENT_MAX_AGE,\n CONSENT_VERSION,\n DEFAULT_CONSENT,\n getConsentTexts,\n parseConsent,\n REJECT_ALL_CONSENT,\n serializeConsent,\n setDefaultConsent,\n updateConsent,\n} from './modules/consent/index.js'\nexport type { ConsentCategory, ConsentState, ConsentTexts } from './modules/consent/index.js'\nexport type {\n ContentCollectionOption,\n ContentOption,\n ResolvedRoute,\n} from './modules/content/index.js'\nexport {\n archiveFieldName,\n buildArchivePath,\n buildEntryPath,\n getArchiveEntries,\n parsePageParam,\n resolveRoute,\n} from './modules/content/index.js'\nexport type { ArchiveEntries } from './modules/content/index.js'\nexport { graphAdapter } from './modules/email/graphAdapter.js'\nexport type { GraphAdapterArgs } from './modules/email/graphAdapter.js'\nexport { mailAdapter } from './modules/email/mailAdapter.js'\nexport type { MailAdapterArgs } from './modules/email/mailAdapter.js'\n// Imported straight from the file, NOT from ./modules/email/index.js — that\n// barrel re-exports sendEmail, which imports 'server-only' and would crash when\n// Payload loads the config (or runs generate:importmap) as a plain Node script.\nexport { panelSmtpAdapter } from './modules/email/panelSmtpAdapter.js'\nexport type { PanelSmtpAdapterArgs } from './modules/email/panelSmtpAdapter.js'\nexport { buildFormsPlugin } from './modules/forms/formsPluginConfig.js'\nexport type {\n FormsCollectionOverrides,\n FormsFieldsOverride,\n FormsOption,\n} from './modules/forms/types.js'\nexport { createContentHelpers } from './modules/frontend/index.js'\nexport type { I18nConfig, LocaleDefinition, LocalizedSlugs } from './modules/i18n/index.js'\nexport {\n buildLocalizedPath,\n getDefaultLocale,\n getLocaleCodes,\n getLocaleDefinition,\n getLocalizedSlugs,\n isValidLocale,\n LOCALE_COOKIE_NAME,\n matchAcceptLanguage,\n negotiateLocale,\n switchLocalePath,\n} from './modules/i18n/index.js'\nexport type { LocaleMiddlewareResult } from './modules/i18n/index.js'\nexport { createLocaleMiddleware, DEFAULT_MIDDLEWARE_MATCHER } from './modules/i18n/index.js'\n// Media — filename normalization hook for upload collections (Media).\nexport { normalizeFilename, normalizeFilenameHook } from './modules/media/index.js'\nexport {\n getNotificationTexts,\n NOTIFICATION_FALLBACK,\n resolveFormMessage,\n} from './modules/notifications/index.js'\nexport type {\n FormNotificationTexts,\n NotificationsData,\n NotificationTexts,\n} from './modules/notifications/index.js'\nexport type { PagesOption, SystemPageRole } from './modules/pages/index.js'\nexport { ALL_SYSTEM_PAGE_ROLES, getSystemPagePath } from './modules/pages/index.js'\nexport type { GlobalQueryOptions } from './modules/payload/index.js'\nexport {\n getGlobal,\n getSiteIntegrations,\n getSiteSettings,\n SITE_INTEGRATIONS_SLUG,\n SITE_SETTINGS_SLUG,\n} from './modules/payload/index.js'\nexport { buildSecurityHeaders } from './modules/security/index.js'\nexport type { BuildSecurityHeadersArgs, SecurityHeader } from './modules/security/index.js'\nexport {\n buildIconsMetadata,\n buildOrganizationJsonLd,\n validateFaviconField,\n} from './modules/seo/index.js'\nexport type { PageMetadata, SeoMeta, SeoOption } from './modules/seo/index.js'\nexport { buildHreflangAlternates, buildMetadata, composeTitle } from './modules/seo/index.js'\nexport type { AutoFillMapping, RobotsRules, SitemapEntry } from './modules/seo/index.js'\nexport {\n buildAutoFillMetaHook,\n buildRobots,\n buildSitemapEntries,\n createMetadataGenerator,\n createPageMetadata,\n injectAutoFillMeta,\n} from './modules/seo/index.js'\nexport { buildSlugField, toSlug } from './modules/slug/index.js'\n// Storage — Cloudflare R2 media offload, configured from .env.\nexport { buildR2Storage } from './modules/storage/index.js'\n\nexport { ipalKit } from './plugin.js'\nexport type { IpalOptions } from './types.js'\n"],"names":["adminOnly","adminOnlyField","adminOrEditor","adminOrEditorField","adminOrSelf","authenticated","hasMinimumRole","isAdmin","isEditor","requireRole","requireRoleField","ROLE_HIERARCHY","getAnalyticsConfig","ACCEPT_ALL_CONSENT","CONSENT_CATEGORIES","CONSENT_COOKIE","CONSENT_MAX_AGE","CONSENT_VERSION","DEFAULT_CONSENT","getConsentTexts","parseConsent","REJECT_ALL_CONSENT","serializeConsent","setDefaultConsent","updateConsent","archiveFieldName","buildArchivePath","buildEntryPath","getArchiveEntries","parsePageParam","resolveRoute","graphAdapter","mailAdapter","panelSmtpAdapter","buildFormsPlugin","createContentHelpers","buildLocalizedPath","getDefaultLocale","getLocaleCodes","getLocaleDefinition","getLocalizedSlugs","isValidLocale","LOCALE_COOKIE_NAME","matchAcceptLanguage","negotiateLocale","switchLocalePath","createLocaleMiddleware","DEFAULT_MIDDLEWARE_MATCHER","normalizeFilename","normalizeFilenameHook","getNotificationTexts","NOTIFICATION_FALLBACK","resolveFormMessage","ALL_SYSTEM_PAGE_ROLES","getSystemPagePath","getGlobal","getSiteIntegrations","getSiteSettings","SITE_INTEGRATIONS_SLUG","SITE_SETTINGS_SLUG","buildSecurityHeaders","buildIconsMetadata","buildOrganizationJsonLd","validateFaviconField","buildHreflangAlternates","buildMetadata","composeTitle","buildAutoFillMetaHook","buildRobots","buildSitemapEntries","createMetadataGenerator","createPageMetadata","injectAutoFillMeta","buildSlugField","toSlug","buildR2Storage","ipalKit"],"mappings":"AACA,SACEA,SAAS,EACTC,cAAc,EACdC,aAAa,EACbC,kBAAkB,EAClBC,WAAW,EACXC,aAAa,EACbC,cAAc,EACdC,OAAO,EACPC,QAAQ,EACRC,WAAW,EACXC,gBAAgB,EAChBC,cAAc,QACT,4BAA2B;AAElC,SAASC,kBAAkB,QAAQ,+BAA8B;AACjE,SACEC,kBAAkB,EAClBC,kBAAkB,EAClBC,cAAc,EACdC,eAAe,EACfC,eAAe,EACfC,eAAe,EACfC,eAAe,EACfC,YAAY,EACZC,kBAAkB,EAClBC,gBAAgB,EAChBC,iBAAiB,EACjBC,aAAa,QACR,6BAA4B;AAOnC,SACEC,gBAAgB,EAChBC,gBAAgB,EAChBC,cAAc,EACdC,iBAAiB,EACjBC,cAAc,EACdC,YAAY,QACP,6BAA4B;AAEnC,SAASC,YAAY,QAAQ,kCAAiC;AAE9D,SAASC,WAAW,QAAQ,iCAAgC;AAE5D,4EAA4E;AAC5E,gFAAgF;AAChF,gFAAgF;AAChF,SAASC,gBAAgB,QAAQ,sCAAqC;AAEtE,SAASC,gBAAgB,QAAQ,uCAAsC;AAMvE,SAASC,oBAAoB,QAAQ,8BAA6B;AAElE,SACEC,kBAAkB,EAClBC,gBAAgB,EAChBC,cAAc,EACdC,mBAAmB,EACnBC,iBAAiB,EACjBC,aAAa,EACbC,kBAAkB,EAClBC,mBAAmB,EACnBC,eAAe,EACfC,gBAAgB,QACX,0BAAyB;AAEhC,SAASC,sBAAsB,EAAEC,0BAA0B,QAAQ,0BAAyB;AAC5F,sEAAsE;AACtE,SAASC,iBAAiB,EAAEC,qBAAqB,QAAQ,2BAA0B;AACnF,SACEC,oBAAoB,EACpBC,qBAAqB,EACrBC,kBAAkB,QACb,mCAAkC;AAOzC,SAASC,qBAAqB,EAAEC,iBAAiB,QAAQ,2BAA0B;AAEnF,SACEC,SAAS,EACTC,mBAAmB,EACnBC,eAAe,EACfC,sBAAsB,EACtBC,kBAAkB,QACb,6BAA4B;AACnC,SAASC,oBAAoB,QAAQ,8BAA6B;AAElE,SACEC,kBAAkB,EAClBC,uBAAuB,EACvBC,oBAAoB,QACf,yBAAwB;AAE/B,SAASC,uBAAuB,EAAEC,aAAa,EAAEC,YAAY,QAAQ,yBAAwB;AAE7F,SACEC,qBAAqB,EACrBC,WAAW,EACXC,mBAAmB,EACnBC,uBAAuB,EACvBC,kBAAkB,EAClBC,kBAAkB,QACb,yBAAwB;AAC/B,SAASC,cAAc,EAAEC,MAAM,QAAQ,0BAAyB;AAChE,+DAA+D;AAC/D,SAASC,cAAc,QAAQ,6BAA4B;AAE3D,SAASC,OAAO,QAAQ,cAAa"}
\ No newline at end of file
+{"version":3,"sources":["../src/index.ts"],"sourcesContent":["export type { AccessOption, Role } from './modules/access/index.js'\nexport {\n adminOnly,\n adminOnlyField,\n adminOrEditor,\n adminOrEditorField,\n adminOrSelf,\n authenticated,\n hasMinimumRole,\n isAdmin,\n isEditor,\n requireRole,\n requireRoleField,\n ROLE_HIERARCHY,\n} from './modules/access/index.js'\nexport type { AnalyticsConfig } from './modules/analytics/index.js'\nexport { getAnalyticsConfig } from './modules/analytics/index.js'\nexport {\n ACCEPT_ALL_CONSENT,\n CONSENT_CATEGORIES,\n CONSENT_COOKIE,\n CONSENT_MAX_AGE,\n CONSENT_VERSION,\n DEFAULT_CONSENT,\n getConsentTexts,\n parseConsent,\n REJECT_ALL_CONSENT,\n serializeConsent,\n setDefaultConsent,\n updateConsent,\n} from './modules/consent/index.js'\nexport type { ConsentCategory, ConsentState, ConsentTexts } from './modules/consent/index.js'\nexport type {\n ContentCollectionOption,\n ContentOption,\n ResolvedRoute,\n} from './modules/content/index.js'\nexport {\n archiveFieldName,\n buildArchivePath,\n buildEntryPath,\n getArchiveEntries,\n parsePageParam,\n resolveRoute,\n} from './modules/content/index.js'\nexport type { ArchiveEntries } from './modules/content/index.js'\nexport { graphAdapter } from './modules/email/graphAdapter.js'\nexport type { GraphAdapterArgs } from './modules/email/graphAdapter.js'\nexport { mailAdapter } from './modules/email/mailAdapter.js'\nexport type { MailAdapterArgs } from './modules/email/mailAdapter.js'\n// Imported straight from the file, NOT from ./modules/email/index.js — that\n// barrel re-exports sendEmail, which imports 'server-only' and would crash when\n// Payload loads the config (or runs generate:importmap) as a plain Node script.\nexport { panelSmtpAdapter } from './modules/email/panelSmtpAdapter.js'\nexport type { PanelSmtpAdapterArgs } from './modules/email/panelSmtpAdapter.js'\nexport { buildFormsPlugin } from './modules/forms/formsPluginConfig.js'\nexport type {\n FormsCollectionOverrides,\n FormsFieldsOverride,\n FormsOption,\n} from './modules/forms/types.js'\nexport { createContentHelpers } from './modules/frontend/index.js'\nexport type { I18nConfig, LocaleDefinition, LocalizedSlugs } from './modules/i18n/index.js'\nexport {\n buildLocalizedPath,\n getDefaultLocale,\n getLocaleCodes,\n getLocaleDefinition,\n getLocalizedSlugs,\n isValidLocale,\n LOCALE_COOKIE_NAME,\n matchAcceptLanguage,\n negotiateLocale,\n switchLocalePath,\n} from './modules/i18n/index.js'\nexport type { LocaleMiddlewareResult } from './modules/i18n/index.js'\nexport { createLocaleMiddleware, DEFAULT_MIDDLEWARE_MATCHER } from './modules/i18n/index.js'\n// Media — filename normalization hook for upload collections (Media).\nexport { normalizeFilename, normalizeFilenameHook } from './modules/media/index.js'\nexport {\n getNotificationTexts,\n NOTIFICATION_FALLBACK,\n resolveFormMessage,\n} from './modules/notifications/index.js'\nexport type {\n FormNotificationTexts,\n NotificationsData,\n NotificationTexts,\n} from './modules/notifications/index.js'\nexport type { PagesOption, SystemPageRole } from './modules/pages/index.js'\nexport { ALL_SYSTEM_PAGE_ROLES, getSystemPagePath } from './modules/pages/index.js'\nexport type { GlobalQueryOptions } from './modules/payload/index.js'\nexport {\n getGlobal,\n getSiteIntegrations,\n getSiteSettings,\n SITE_INTEGRATIONS_SLUG,\n SITE_SETTINGS_SLUG,\n} from './modules/payload/index.js'\nexport { buildSecurityHeaders } from './modules/security/index.js'\nexport type { BuildSecurityHeadersArgs, SecurityHeader } from './modules/security/index.js'\nexport {\n buildIconsMetadata,\n buildOrganizationJsonLd,\n validateFaviconField,\n} from './modules/seo/index.js'\n// Structured data (schema.org JSON-LD) — brand/sitelink signals for Google.\n// WebSite (+ optional SearchAction), BreadcrumbList (per page), SiteNavigation.\nexport {\n buildBreadcrumbJsonLd,\n buildSiteNavigationJsonLd,\n buildWebSiteJsonLd,\n} from './modules/seo/index.js'\nexport type { PageMetadata, SeoMeta, SeoOption } from './modules/seo/index.js'\nexport { buildHreflangAlternates, buildMetadata, composeTitle } from './modules/seo/index.js'\nexport type { AutoFillMapping, RobotsRules, SitemapEntry } from './modules/seo/index.js'\nexport {\n buildAutoFillMetaHook,\n buildRobots,\n buildSitemapEntries,\n createMetadataGenerator,\n createPageMetadata,\n injectAutoFillMeta,\n} from './modules/seo/index.js'\nexport { buildSlugField, toSlug } from './modules/slug/index.js'\n// Storage — Cloudflare R2 media offload, configured from .env.\nexport { buildR2Storage } from './modules/storage/index.js'\n\nexport { ipalKit } from './plugin.js'\nexport type { IpalOptions } from './types.js'\n"],"names":["adminOnly","adminOnlyField","adminOrEditor","adminOrEditorField","adminOrSelf","authenticated","hasMinimumRole","isAdmin","isEditor","requireRole","requireRoleField","ROLE_HIERARCHY","getAnalyticsConfig","ACCEPT_ALL_CONSENT","CONSENT_CATEGORIES","CONSENT_COOKIE","CONSENT_MAX_AGE","CONSENT_VERSION","DEFAULT_CONSENT","getConsentTexts","parseConsent","REJECT_ALL_CONSENT","serializeConsent","setDefaultConsent","updateConsent","archiveFieldName","buildArchivePath","buildEntryPath","getArchiveEntries","parsePageParam","resolveRoute","graphAdapter","mailAdapter","panelSmtpAdapter","buildFormsPlugin","createContentHelpers","buildLocalizedPath","getDefaultLocale","getLocaleCodes","getLocaleDefinition","getLocalizedSlugs","isValidLocale","LOCALE_COOKIE_NAME","matchAcceptLanguage","negotiateLocale","switchLocalePath","createLocaleMiddleware","DEFAULT_MIDDLEWARE_MATCHER","normalizeFilename","normalizeFilenameHook","getNotificationTexts","NOTIFICATION_FALLBACK","resolveFormMessage","ALL_SYSTEM_PAGE_ROLES","getSystemPagePath","getGlobal","getSiteIntegrations","getSiteSettings","SITE_INTEGRATIONS_SLUG","SITE_SETTINGS_SLUG","buildSecurityHeaders","buildIconsMetadata","buildOrganizationJsonLd","validateFaviconField","buildBreadcrumbJsonLd","buildSiteNavigationJsonLd","buildWebSiteJsonLd","buildHreflangAlternates","buildMetadata","composeTitle","buildAutoFillMetaHook","buildRobots","buildSitemapEntries","createMetadataGenerator","createPageMetadata","injectAutoFillMeta","buildSlugField","toSlug","buildR2Storage","ipalKit"],"mappings":"AACA,SACEA,SAAS,EACTC,cAAc,EACdC,aAAa,EACbC,kBAAkB,EAClBC,WAAW,EACXC,aAAa,EACbC,cAAc,EACdC,OAAO,EACPC,QAAQ,EACRC,WAAW,EACXC,gBAAgB,EAChBC,cAAc,QACT,4BAA2B;AAElC,SAASC,kBAAkB,QAAQ,+BAA8B;AACjE,SACEC,kBAAkB,EAClBC,kBAAkB,EAClBC,cAAc,EACdC,eAAe,EACfC,eAAe,EACfC,eAAe,EACfC,eAAe,EACfC,YAAY,EACZC,kBAAkB,EAClBC,gBAAgB,EAChBC,iBAAiB,EACjBC,aAAa,QACR,6BAA4B;AAOnC,SACEC,gBAAgB,EAChBC,gBAAgB,EAChBC,cAAc,EACdC,iBAAiB,EACjBC,cAAc,EACdC,YAAY,QACP,6BAA4B;AAEnC,SAASC,YAAY,QAAQ,kCAAiC;AAE9D,SAASC,WAAW,QAAQ,iCAAgC;AAE5D,4EAA4E;AAC5E,gFAAgF;AAChF,gFAAgF;AAChF,SAASC,gBAAgB,QAAQ,sCAAqC;AAEtE,SAASC,gBAAgB,QAAQ,uCAAsC;AAMvE,SAASC,oBAAoB,QAAQ,8BAA6B;AAElE,SACEC,kBAAkB,EAClBC,gBAAgB,EAChBC,cAAc,EACdC,mBAAmB,EACnBC,iBAAiB,EACjBC,aAAa,EACbC,kBAAkB,EAClBC,mBAAmB,EACnBC,eAAe,EACfC,gBAAgB,QACX,0BAAyB;AAEhC,SAASC,sBAAsB,EAAEC,0BAA0B,QAAQ,0BAAyB;AAC5F,sEAAsE;AACtE,SAASC,iBAAiB,EAAEC,qBAAqB,QAAQ,2BAA0B;AACnF,SACEC,oBAAoB,EACpBC,qBAAqB,EACrBC,kBAAkB,QACb,mCAAkC;AAOzC,SAASC,qBAAqB,EAAEC,iBAAiB,QAAQ,2BAA0B;AAEnF,SACEC,SAAS,EACTC,mBAAmB,EACnBC,eAAe,EACfC,sBAAsB,EACtBC,kBAAkB,QACb,6BAA4B;AACnC,SAASC,oBAAoB,QAAQ,8BAA6B;AAElE,SACEC,kBAAkB,EAClBC,uBAAuB,EACvBC,oBAAoB,QACf,yBAAwB;AAC/B,4EAA4E;AAC5E,gFAAgF;AAChF,SACEC,qBAAqB,EACrBC,yBAAyB,EACzBC,kBAAkB,QACb,yBAAwB;AAE/B,SAASC,uBAAuB,EAAEC,aAAa,EAAEC,YAAY,QAAQ,yBAAwB;AAE7F,SACEC,qBAAqB,EACrBC,WAAW,EACXC,mBAAmB,EACnBC,uBAAuB,EACvBC,kBAAkB,EAClBC,kBAAkB,QACb,yBAAwB;AAC/B,SAASC,cAAc,EAAEC,MAAM,QAAQ,0BAAyB;AAChE,+DAA+D;AAC/D,SAASC,cAAc,QAAQ,6BAA4B;AAE3D,SAASC,OAAO,QAAQ,cAAa"}
\ No newline at end of file
diff --git a/dist/modules/seo/buildBreadcrumbJsonLd.d.ts b/dist/modules/seo/buildBreadcrumbJsonLd.d.ts
new file mode 100644
index 0000000..90e5eb3
--- /dev/null
+++ b/dist/modules/seo/buildBreadcrumbJsonLd.d.ts
@@ -0,0 +1,40 @@
+type Crumb = {
+ /** Visible name of the breadcrumb (e.g. 'Usługi'). */
+ name: string;
+ /** Absolute URL of this crumb (e.g. 'https://example.com/pl/uslugi'). */
+ url: string;
+};
+/**
+ * Builds BreadcrumbList JSON-LD (schema.org) for a page's position in the site
+ * hierarchy. Google can show breadcrumbs in the result (Dom › Usługi › Detailing)
+ * and uses them to understand structure — a signal that helps navigational
+ * results and sitelinks.
+ *
+ * Unlike Organization/WebSite (site-wide, root layout), breadcrumbs are
+ * PER-PAGE — build them from the page's ancestry and emit on that page:
+ *
+ * import { buildBreadcrumbJsonLd } from '@intecion/ipal-kit'
+ * const jsonLd = buildBreadcrumbJsonLd([
+ * { name: 'Strona główna', url: `${base}/pl` },
+ * { name: 'Usługi', url: `${base}/pl/uslugi` },
+ * { name: 'Detailing', url: `${base}/pl/uslugi/detailing` },
+ * ])
+ *
+ *
+ * The crumb data comes from the page's real position (parent pages / URL path),
+ * NOT hardcoded. Derive it from the resolved route, not a static list.
+ *
+ * Returns null for an empty/single crumb list — a one-item breadcrumb isn't
+ * meaningful and shouldn't be emitted.
+ */
+export declare function buildBreadcrumbJsonLd(crumbs: Crumb[]): {
+ '@context': string;
+ '@type': string;
+ itemListElement: {
+ name: string;
+ '@type': string;
+ item: string;
+ position: number;
+ }[];
+} | null;
+export {};
diff --git a/dist/modules/seo/buildBreadcrumbJsonLd.js b/dist/modules/seo/buildBreadcrumbJsonLd.js
new file mode 100644
index 0000000..e3335b4
--- /dev/null
+++ b/dist/modules/seo/buildBreadcrumbJsonLd.js
@@ -0,0 +1,39 @@
+/**
+ * Builds BreadcrumbList JSON-LD (schema.org) for a page's position in the site
+ * hierarchy. Google can show breadcrumbs in the result (Dom › Usługi › Detailing)
+ * and uses them to understand structure — a signal that helps navigational
+ * results and sitelinks.
+ *
+ * Unlike Organization/WebSite (site-wide, root layout), breadcrumbs are
+ * PER-PAGE — build them from the page's ancestry and emit on that page:
+ *
+ * import { buildBreadcrumbJsonLd } from '@intecion/ipal-kit'
+ * const jsonLd = buildBreadcrumbJsonLd([
+ * { name: 'Strona główna', url: `${base}/pl` },
+ * { name: 'Usługi', url: `${base}/pl/uslugi` },
+ * { name: 'Detailing', url: `${base}/pl/uslugi/detailing` },
+ * ])
+ *
+ *
+ * The crumb data comes from the page's real position (parent pages / URL path),
+ * NOT hardcoded. Derive it from the resolved route, not a static list.
+ *
+ * Returns null for an empty/single crumb list — a one-item breadcrumb isn't
+ * meaningful and shouldn't be emitted.
+ */ export function buildBreadcrumbJsonLd(crumbs) {
+ if (!crumbs || crumbs.length < 2) {
+ return null;
+ }
+ return {
+ '@context': 'https://schema.org',
+ '@type': 'BreadcrumbList',
+ itemListElement: crumbs.map((crumb, index)=>({
+ name: crumb.name,
+ '@type': 'ListItem',
+ item: crumb.url,
+ position: index + 1
+ }))
+ };
+}
+
+//# sourceMappingURL=buildBreadcrumbJsonLd.js.map
\ No newline at end of file
diff --git a/dist/modules/seo/buildBreadcrumbJsonLd.js.map b/dist/modules/seo/buildBreadcrumbJsonLd.js.map
new file mode 100644
index 0000000..f4050c6
--- /dev/null
+++ b/dist/modules/seo/buildBreadcrumbJsonLd.js.map
@@ -0,0 +1 @@
+{"version":3,"sources":["../../../src/modules/seo/buildBreadcrumbJsonLd.ts"],"sourcesContent":["type Crumb = {\n /** Visible name of the breadcrumb (e.g. 'Usługi'). */\n name: string\n /** Absolute URL of this crumb (e.g. 'https://example.com/pl/uslugi'). */\n url: string\n}\n\n/**\n * Builds BreadcrumbList JSON-LD (schema.org) for a page's position in the site\n * hierarchy. Google can show breadcrumbs in the result (Dom › Usługi › Detailing)\n * and uses them to understand structure — a signal that helps navigational\n * results and sitelinks.\n *\n * Unlike Organization/WebSite (site-wide, root layout), breadcrumbs are\n * PER-PAGE — build them from the page's ancestry and emit on that page:\n *\n * import { buildBreadcrumbJsonLd } from '@intecion/ipal-kit'\n * const jsonLd = buildBreadcrumbJsonLd([\n * { name: 'Strona główna', url: `${base}/pl` },\n * { name: 'Usługi', url: `${base}/pl/uslugi` },\n * { name: 'Detailing', url: `${base}/pl/uslugi/detailing` },\n * ])\n * \n *\n * The crumb data comes from the page's real position (parent pages / URL path),\n * NOT hardcoded. Derive it from the resolved route, not a static list.\n *\n * Returns null for an empty/single crumb list — a one-item breadcrumb isn't\n * meaningful and shouldn't be emitted.\n */\nexport function buildBreadcrumbJsonLd(crumbs: Crumb[]) {\n if (!crumbs || crumbs.length < 2) {return null}\n\n return {\n '@context': 'https://schema.org',\n '@type': 'BreadcrumbList',\n itemListElement: crumbs.map((crumb, index) => ({\n name: crumb.name,\n '@type': 'ListItem',\n item: crumb.url,\n position: index + 1,\n })),\n }\n}\n"],"names":["buildBreadcrumbJsonLd","crumbs","length","itemListElement","map","crumb","index","name","item","url","position"],"mappings":"AAOA;;;;;;;;;;;;;;;;;;;;;;CAsBC,GACD,OAAO,SAASA,sBAAsBC,MAAe;IACnD,IAAI,CAACA,UAAUA,OAAOC,MAAM,GAAG,GAAG;QAAC,OAAO;IAAI;IAE9C,OAAO;QACL,YAAY;QACZ,SAAS;QACTC,iBAAiBF,OAAOG,GAAG,CAAC,CAACC,OAAOC,QAAW,CAAA;gBAC7CC,MAAMF,MAAME,IAAI;gBAChB,SAAS;gBACTC,MAAMH,MAAMI,GAAG;gBACfC,UAAUJ,QAAQ;YACpB,CAAA;IACF;AACF"}
\ No newline at end of file
diff --git a/dist/modules/seo/buildSiteNavigationJsonLd.d.ts b/dist/modules/seo/buildSiteNavigationJsonLd.d.ts
new file mode 100644
index 0000000..85285b8
--- /dev/null
+++ b/dist/modules/seo/buildSiteNavigationJsonLd.d.ts
@@ -0,0 +1,35 @@
+type NavItem = {
+ /** Visible label (e.g. 'Usługi'). */
+ name: string;
+ /** Absolute URL (e.g. 'https://example.com/pl/uslugi'). */
+ url: string;
+};
+/**
+ * Builds SiteNavigationElement JSON-LD (schema.org) from the main navigation —
+ * declares the site's primary nav as structured data. A weaker sitelinks signal
+ * than WebSite/breadcrumbs, but cheap: it tells Google which pages are the main
+ * navigation targets.
+ *
+ * Feed it the SAME nav items the header renders (from the panel/nav global), so
+ * the structured data matches the visible menu — not a separate hardcoded list.
+ *
+ * import { buildSiteNavigationJsonLd } from '@intecion/ipal-kit'
+ * const jsonLd = buildSiteNavigationJsonLd(
+ * navItems.map(i => ({ name: i.label, url: `${base}${i.href}` }))
+ * )
+ *
+ *
+ * Emit once (site-wide, root layout). Data from the nav source, never hardcoded.
+ * Returns null for empty nav.
+ */
+export declare function buildSiteNavigationJsonLd(items: NavItem[]): {
+ '@context': string;
+ '@type': string;
+ itemListElement: {
+ name: string;
+ '@type': string;
+ position: number;
+ url: string;
+ }[];
+} | null;
+export {};
diff --git a/dist/modules/seo/buildSiteNavigationJsonLd.js b/dist/modules/seo/buildSiteNavigationJsonLd.js
new file mode 100644
index 0000000..64e9218
--- /dev/null
+++ b/dist/modules/seo/buildSiteNavigationJsonLd.js
@@ -0,0 +1,34 @@
+/**
+ * Builds SiteNavigationElement JSON-LD (schema.org) from the main navigation —
+ * declares the site's primary nav as structured data. A weaker sitelinks signal
+ * than WebSite/breadcrumbs, but cheap: it tells Google which pages are the main
+ * navigation targets.
+ *
+ * Feed it the SAME nav items the header renders (from the panel/nav global), so
+ * the structured data matches the visible menu — not a separate hardcoded list.
+ *
+ * import { buildSiteNavigationJsonLd } from '@intecion/ipal-kit'
+ * const jsonLd = buildSiteNavigationJsonLd(
+ * navItems.map(i => ({ name: i.label, url: `${base}${i.href}` }))
+ * )
+ *
+ *
+ * Emit once (site-wide, root layout). Data from the nav source, never hardcoded.
+ * Returns null for empty nav.
+ */ export function buildSiteNavigationJsonLd(items) {
+ if (!items || items.length === 0) {
+ return null;
+ }
+ return {
+ '@context': 'https://schema.org',
+ '@type': 'ItemList',
+ itemListElement: items.map((item, index)=>({
+ name: item.name,
+ '@type': 'SiteNavigationElement',
+ position: index + 1,
+ url: item.url
+ }))
+ };
+}
+
+//# sourceMappingURL=buildSiteNavigationJsonLd.js.map
\ No newline at end of file
diff --git a/dist/modules/seo/buildSiteNavigationJsonLd.js.map b/dist/modules/seo/buildSiteNavigationJsonLd.js.map
new file mode 100644
index 0000000..82188be
--- /dev/null
+++ b/dist/modules/seo/buildSiteNavigationJsonLd.js.map
@@ -0,0 +1 @@
+{"version":3,"sources":["../../../src/modules/seo/buildSiteNavigationJsonLd.ts"],"sourcesContent":["type NavItem = {\n /** Visible label (e.g. 'Usługi'). */\n name: string\n /** Absolute URL (e.g. 'https://example.com/pl/uslugi'). */\n url: string\n}\n\n/**\n * Builds SiteNavigationElement JSON-LD (schema.org) from the main navigation —\n * declares the site's primary nav as structured data. A weaker sitelinks signal\n * than WebSite/breadcrumbs, but cheap: it tells Google which pages are the main\n * navigation targets.\n *\n * Feed it the SAME nav items the header renders (from the panel/nav global), so\n * the structured data matches the visible menu — not a separate hardcoded list.\n *\n * import { buildSiteNavigationJsonLd } from '@intecion/ipal-kit'\n * const jsonLd = buildSiteNavigationJsonLd(\n * navItems.map(i => ({ name: i.label, url: `${base}${i.href}` }))\n * )\n * \n *\n * Emit once (site-wide, root layout). Data from the nav source, never hardcoded.\n * Returns null for empty nav.\n */\nexport function buildSiteNavigationJsonLd(items: NavItem[]) {\n if (!items || items.length === 0) {return null}\n\n return {\n '@context': 'https://schema.org',\n '@type': 'ItemList',\n itemListElement: items.map((item, index) => ({\n name: item.name,\n '@type': 'SiteNavigationElement',\n position: index + 1,\n url: item.url,\n })),\n }\n}\n"],"names":["buildSiteNavigationJsonLd","items","length","itemListElement","map","item","index","name","position","url"],"mappings":"AAOA;;;;;;;;;;;;;;;;;CAiBC,GACD,OAAO,SAASA,0BAA0BC,KAAgB;IACxD,IAAI,CAACA,SAASA,MAAMC,MAAM,KAAK,GAAG;QAAC,OAAO;IAAI;IAE9C,OAAO;QACL,YAAY;QACZ,SAAS;QACTC,iBAAiBF,MAAMG,GAAG,CAAC,CAACC,MAAMC,QAAW,CAAA;gBAC3CC,MAAMF,KAAKE,IAAI;gBACf,SAAS;gBACTC,UAAUF,QAAQ;gBAClBG,KAAKJ,KAAKI,GAAG;YACf,CAAA;IACF;AACF"}
\ No newline at end of file
diff --git a/dist/modules/seo/buildWebSiteJsonLd.d.ts b/dist/modules/seo/buildWebSiteJsonLd.d.ts
new file mode 100644
index 0000000..3e32b4c
--- /dev/null
+++ b/dist/modules/seo/buildWebSiteJsonLd.d.ts
@@ -0,0 +1,55 @@
+type SearchActionConfig = {
+ /**
+ * URL template for site search, with {search_term_string} placeholder.
+ * e.g. 'https://example.com/szukaj?q={search_term_string}'. Only include if
+ * the site actually HAS a working search page — a SearchAction pointing at a
+ * non-existent search does more harm than good.
+ */
+ target: string;
+};
+type WebSiteJsonLdArgs = {
+ /** Site name (from panel — siteName). */
+ name: string;
+ /**
+ * Optional site search. Enables the "sitelinks searchbox" — a search field
+ * Google may show under the brand result. Only pass when a real search page
+ * exists. Omit entirely otherwise.
+ */
+ search?: SearchActionConfig;
+ /** Absolute site URL (https://…). */
+ url: string;
+};
+/**
+ * Builds WebSite JSON-LD (schema.org). Two jobs:
+ * - Declares the site + name (helps Google associate brand queries with the site).
+ * - Optionally declares a SearchAction, which is what can produce the "sitelinks
+ * searchbox" (a search field under the brand result in Google).
+ *
+ * IMPORTANT — sitelinks (the sub-links under a result) CANNOT be forced. No
+ * schema guarantees them; Google generates them algorithmically from site
+ * structure, internal links, clear titles, and ranking. This schema is a SIGNAL
+ * that improves the odds and can enable the searchbox — not a switch. Manage
+ * expectations accordingly (see docs/seo.md).
+ *
+ * Emit once in the ROOT layout (site-wide), from panel data:
+ *
+ * import { buildWebSiteJsonLd } from '@intecion/ipal-kit'
+ * const jsonLd = buildWebSiteJsonLd({ name: settings.siteName, url: baseUrl })
+ *
+ */
+export declare function buildWebSiteJsonLd({ name, search, url }: WebSiteJsonLdArgs): {
+ potentialAction?: {
+ '@type': string;
+ 'query-input': string;
+ target: {
+ '@type': string;
+ urlTemplate: string;
+ };
+ } | undefined;
+ name: string;
+ '@context': string;
+ '@type': string;
+ url: string;
+};
+export {};
diff --git a/dist/modules/seo/buildWebSiteJsonLd.js b/dist/modules/seo/buildWebSiteJsonLd.js
new file mode 100644
index 0000000..8c01edb
--- /dev/null
+++ b/dist/modules/seo/buildWebSiteJsonLd.js
@@ -0,0 +1,38 @@
+/**
+ * Builds WebSite JSON-LD (schema.org). Two jobs:
+ * - Declares the site + name (helps Google associate brand queries with the site).
+ * - Optionally declares a SearchAction, which is what can produce the "sitelinks
+ * searchbox" (a search field under the brand result in Google).
+ *
+ * IMPORTANT — sitelinks (the sub-links under a result) CANNOT be forced. No
+ * schema guarantees them; Google generates them algorithmically from site
+ * structure, internal links, clear titles, and ranking. This schema is a SIGNAL
+ * that improves the odds and can enable the searchbox — not a switch. Manage
+ * expectations accordingly (see docs/seo.md).
+ *
+ * Emit once in the ROOT layout (site-wide), from panel data:
+ *
+ * import { buildWebSiteJsonLd } from '@intecion/ipal-kit'
+ * const jsonLd = buildWebSiteJsonLd({ name: settings.siteName, url: baseUrl })
+ *
+ */ export function buildWebSiteJsonLd({ name, search, url }) {
+ return {
+ name,
+ '@context': 'https://schema.org',
+ '@type': 'WebSite',
+ url,
+ ...search ? {
+ potentialAction: {
+ '@type': 'SearchAction',
+ 'query-input': 'required name=search_term_string',
+ target: {
+ '@type': 'EntryPoint',
+ urlTemplate: search.target
+ }
+ }
+ } : {}
+ };
+}
+
+//# sourceMappingURL=buildWebSiteJsonLd.js.map
\ No newline at end of file
diff --git a/dist/modules/seo/buildWebSiteJsonLd.js.map b/dist/modules/seo/buildWebSiteJsonLd.js.map
new file mode 100644
index 0000000..f3055c6
--- /dev/null
+++ b/dist/modules/seo/buildWebSiteJsonLd.js.map
@@ -0,0 +1 @@
+{"version":3,"sources":["../../../src/modules/seo/buildWebSiteJsonLd.ts"],"sourcesContent":["type SearchActionConfig = {\n /**\n * URL template for site search, with {search_term_string} placeholder.\n * e.g. 'https://example.com/szukaj?q={search_term_string}'. Only include if\n * the site actually HAS a working search page — a SearchAction pointing at a\n * non-existent search does more harm than good.\n */\n target: string\n}\n\ntype WebSiteJsonLdArgs = {\n /** Site name (from panel — siteName). */\n name: string\n /**\n * Optional site search. Enables the \"sitelinks searchbox\" — a search field\n * Google may show under the brand result. Only pass when a real search page\n * exists. Omit entirely otherwise.\n */\n search?: SearchActionConfig\n /** Absolute site URL (https://…). */\n url: string\n}\n\n/**\n * Builds WebSite JSON-LD (schema.org). Two jobs:\n * - Declares the site + name (helps Google associate brand queries with the site).\n * - Optionally declares a SearchAction, which is what can produce the \"sitelinks\n * searchbox\" (a search field under the brand result in Google).\n *\n * IMPORTANT — sitelinks (the sub-links under a result) CANNOT be forced. No\n * schema guarantees them; Google generates them algorithmically from site\n * structure, internal links, clear titles, and ranking. This schema is a SIGNAL\n * that improves the odds and can enable the searchbox — not a switch. Manage\n * expectations accordingly (see docs/seo.md).\n *\n * Emit once in the ROOT layout (site-wide), from panel data:\n *\n * import { buildWebSiteJsonLd } from '@intecion/ipal-kit'\n * const jsonLd = buildWebSiteJsonLd({ name: settings.siteName, url: baseUrl })\n * \n */\nexport function buildWebSiteJsonLd({ name, search, url }: WebSiteJsonLdArgs) {\n return {\n name,\n '@context': 'https://schema.org',\n '@type': 'WebSite',\n url,\n ...(search\n ? {\n potentialAction: {\n '@type': 'SearchAction',\n 'query-input': 'required name=search_term_string',\n target: {\n '@type': 'EntryPoint',\n urlTemplate: search.target,\n },\n },\n }\n : {}),\n }\n}\n"],"names":["buildWebSiteJsonLd","name","search","url","potentialAction","target","urlTemplate"],"mappings":"AAuBA;;;;;;;;;;;;;;;;;;CAkBC,GACD,OAAO,SAASA,mBAAmB,EAAEC,IAAI,EAAEC,MAAM,EAAEC,GAAG,EAAqB;IACzE,OAAO;QACLF;QACA,YAAY;QACZ,SAAS;QACTE;QACA,GAAID,SACA;YACEE,iBAAiB;gBACf,SAAS;gBACT,eAAe;gBACfC,QAAQ;oBACN,SAAS;oBACTC,aAAaJ,OAAOG,MAAM;gBAC5B;YACF;QACF,IACA,CAAC,CAAC;IACR;AACF"}
\ No newline at end of file
diff --git a/dist/modules/seo/hreflang.d.ts b/dist/modules/seo/hreflang.d.ts
index f559f0d..ee7756a 100644
--- a/dist/modules/seo/hreflang.d.ts
+++ b/dist/modules/seo/hreflang.d.ts
@@ -15,20 +15,28 @@ type BuildHreflangArgs = {
slugs: LocalizedSlugs;
};
/**
- * Builds a map of locale → URL for hreflang alternate links, suitable for
* Next.js Metadata `alternates.languages`.
*
* Bridges SEO and i18n: for each configured locale that the document has a
* slug in, it produces the locale-aware path (via buildLocalizedPath),
* optionally prefixed with an absolute origin.
*
+ * Also emits `x-default` pointing at the default locale — the version Google
+ * serves when the user's language/region matches no hreflang, and the fallback
+ * when the root ('/') redirect is ambiguous (Googlebot with no/foreign
+ * Accept-Language).
+ *
* @example
* buildHreflangAlternates({
* slugs: { pl: 'o-nas', en: 'about' },
* config,
* baseUrl: 'https://example.com',
* })
- * // → { pl: 'https://example.com/pl/o-nas', en: 'https://example.com/en/about' }
+ * // → {
+ * // pl: 'https://example.com/pl/o-nas',
+ * // en: 'https://example.com/en/about',
+ * // 'x-default': 'https://example.com/pl/o-nas',
+ * // }
*/
export declare function buildHreflangAlternates({ baseUrl, config, homeSlug, prefix, slugs, }: BuildHreflangArgs): Record;
export {};
diff --git a/dist/modules/seo/hreflang.js b/dist/modules/seo/hreflang.js
index 0a74d4e..d122773 100644
--- a/dist/modules/seo/hreflang.js
+++ b/dist/modules/seo/hreflang.js
@@ -1,19 +1,27 @@
-import { buildLocalizedPath, getLocaleCodes } from '../i18n/index.js';
+import { buildLocalizedPath, getDefaultLocale, getLocaleCodes } from '../i18n/index.js';
/**
- * Builds a map of locale → URL for hreflang alternate links, suitable for
* Next.js Metadata `alternates.languages`.
*
* Bridges SEO and i18n: for each configured locale that the document has a
* slug in, it produces the locale-aware path (via buildLocalizedPath),
* optionally prefixed with an absolute origin.
*
+ * Also emits `x-default` pointing at the default locale — the version Google
+ * serves when the user's language/region matches no hreflang, and the fallback
+ * when the root ('/') redirect is ambiguous (Googlebot with no/foreign
+ * Accept-Language).
+ *
* @example
* buildHreflangAlternates({
* slugs: { pl: 'o-nas', en: 'about' },
* config,
* baseUrl: 'https://example.com',
* })
- * // → { pl: 'https://example.com/pl/o-nas', en: 'https://example.com/en/about' }
+ * // → {
+ * // pl: 'https://example.com/pl/o-nas',
+ * // en: 'https://example.com/en/about',
+ * // 'x-default': 'https://example.com/pl/o-nas',
+ * // }
*/ export function buildHreflangAlternates({ baseUrl, config, homeSlug = 'home', prefix, slugs }) {
const origin = baseUrl?.replace(/\/$/, '') ?? '';
const alternates = {};
@@ -29,6 +37,15 @@ import { buildLocalizedPath, getLocaleCodes } from '../i18n/index.js';
alternates[locale] = `${origin}${path}`;
}
}
+ // x-default: the version Google serves when the user's language/region doesn't
+ // match any hreflang — and, crucially here, the fallback when the root ('/')
+ // redirect is ambiguous (Googlebot with no/foreign Accept-Language). Point it
+ // at the default locale (the primary market) so search shows that version by
+ // default instead of guessing. Only set when the default locale has a URL.
+ const defaultLocalePath = alternates[getDefaultLocale(config)];
+ if (defaultLocalePath) {
+ alternates['x-default'] = defaultLocalePath;
+ }
return alternates;
}
diff --git a/dist/modules/seo/hreflang.js.map b/dist/modules/seo/hreflang.js.map
index 4ca97fe..677a9db 100644
--- a/dist/modules/seo/hreflang.js.map
+++ b/dist/modules/seo/hreflang.js.map
@@ -1 +1 @@
-{"version":3,"sources":["../../../src/modules/seo/hreflang.ts"],"sourcesContent":["import type { I18nConfig, LocalizedSlugs } from '../i18n/index.js'\n\nimport { buildLocalizedPath, getLocaleCodes } from '../i18n/index.js'\n\ntype BuildHreflangArgs = {\n /** Absolute site origin, e.g. 'https://example.com'. Omit for relative paths. */\n baseUrl?: string\n config: I18nConfig\n /** Home slug that collapses to the locale root. Defaults to 'home'. */\n homeSlug?: string\n /**\n * Localized segment the document lives under (an archive page's slugs),\n * e.g. { pl: 'artykuly', en: 'articles' }. Locales missing from the prefix\n * are omitted — an entry with no archive in that language has no URL there.\n */\n prefix?: LocalizedSlugs\n /** slug per locale for the current document, e.g. { pl: 'o-nas', en: 'about' } */\n slugs: LocalizedSlugs\n}\n\n/**\n * Builds a map of locale → URL for hreflang alternate links, suitable for\n * Next.js Metadata `alternates.languages`.\n *\n * Bridges SEO and i18n: for each configured locale that the document has a\n * slug in, it produces the locale-aware path (via buildLocalizedPath),\n * optionally prefixed with an absolute origin.\n *\n * @example\n * buildHreflangAlternates({\n * slugs: { pl: 'o-nas', en: 'about' },\n * config,\n * baseUrl: 'https://example.com',\n * })\n * // → { pl: 'https://example.com/pl/o-nas', en: 'https://example.com/en/about' }\n */\nexport function buildHreflangAlternates({\n baseUrl,\n config,\n homeSlug = 'home',\n prefix,\n slugs,\n}: BuildHreflangArgs): Record {\n const origin = baseUrl?.replace(/\\/$/, '') ?? ''\n const alternates: Record = {}\n\n for (const locale of getLocaleCodes(config)) {\n const path = buildLocalizedPath({ config, homeSlug, locale, prefix, slugs })\n if (path) {\n alternates[locale] = `${origin}${path}`\n }\n }\n\n return alternates\n}\n"],"names":["buildLocalizedPath","getLocaleCodes","buildHreflangAlternates","baseUrl","config","homeSlug","prefix","slugs","origin","replace","alternates","locale","path"],"mappings":"AAEA,SAASA,kBAAkB,EAAEC,cAAc,QAAQ,mBAAkB;AAkBrE;;;;;;;;;;;;;;;CAeC,GACD,OAAO,SAASC,wBAAwB,EACtCC,OAAO,EACPC,MAAM,EACNC,WAAW,MAAM,EACjBC,MAAM,EACNC,KAAK,EACa;IAClB,MAAMC,SAASL,SAASM,QAAQ,OAAO,OAAO;IAC9C,MAAMC,aAAqC,CAAC;IAE5C,KAAK,MAAMC,UAAUV,eAAeG,QAAS;QAC3C,MAAMQ,OAAOZ,mBAAmB;YAAEI;YAAQC;YAAUM;YAAQL;YAAQC;QAAM;QAC1E,IAAIK,MAAM;YACRF,UAAU,CAACC,OAAO,GAAG,GAAGH,SAASI,MAAM;QACzC;IACF;IAEA,OAAOF;AACT"}
\ No newline at end of file
+{"version":3,"sources":["../../../src/modules/seo/hreflang.ts"],"sourcesContent":["import type { I18nConfig, LocalizedSlugs } from '../i18n/index.js'\n\nimport { buildLocalizedPath, getDefaultLocale, getLocaleCodes } from '../i18n/index.js'\n\ntype BuildHreflangArgs = {\n /** Absolute site origin, e.g. 'https://example.com'. Omit for relative paths. */\n baseUrl?: string\n config: I18nConfig\n /** Home slug that collapses to the locale root. Defaults to 'home'. */\n homeSlug?: string\n /**\n * Localized segment the document lives under (an archive page's slugs),\n * e.g. { pl: 'artykuly', en: 'articles' }. Locales missing from the prefix\n * are omitted — an entry with no archive in that language has no URL there.\n */\n prefix?: LocalizedSlugs\n /** slug per locale for the current document, e.g. { pl: 'o-nas', en: 'about' } */\n slugs: LocalizedSlugs\n}\n\n/**\n * Next.js Metadata `alternates.languages`.\n *\n * Bridges SEO and i18n: for each configured locale that the document has a\n * slug in, it produces the locale-aware path (via buildLocalizedPath),\n * optionally prefixed with an absolute origin.\n *\n * Also emits `x-default` pointing at the default locale — the version Google\n * serves when the user's language/region matches no hreflang, and the fallback\n * when the root ('/') redirect is ambiguous (Googlebot with no/foreign\n * Accept-Language).\n *\n * @example\n * buildHreflangAlternates({\n * slugs: { pl: 'o-nas', en: 'about' },\n * config,\n * baseUrl: 'https://example.com',\n * })\n * // → {\n * // pl: 'https://example.com/pl/o-nas',\n * // en: 'https://example.com/en/about',\n * // 'x-default': 'https://example.com/pl/o-nas',\n * // }\n */\nexport function buildHreflangAlternates({\n baseUrl,\n config,\n homeSlug = 'home',\n prefix,\n slugs,\n}: BuildHreflangArgs): Record {\n const origin = baseUrl?.replace(/\\/$/, '') ?? ''\n const alternates: Record = {}\n\n for (const locale of getLocaleCodes(config)) {\n const path = buildLocalizedPath({ config, homeSlug, locale, prefix, slugs })\n if (path) {\n alternates[locale] = `${origin}${path}`\n }\n }\n\n // x-default: the version Google serves when the user's language/region doesn't\n // match any hreflang — and, crucially here, the fallback when the root ('/')\n // redirect is ambiguous (Googlebot with no/foreign Accept-Language). Point it\n // at the default locale (the primary market) so search shows that version by\n // default instead of guessing. Only set when the default locale has a URL.\n const defaultLocalePath = alternates[getDefaultLocale(config)]\n if (defaultLocalePath) {\n alternates['x-default'] = defaultLocalePath\n }\n\n return alternates\n}\n"],"names":["buildLocalizedPath","getDefaultLocale","getLocaleCodes","buildHreflangAlternates","baseUrl","config","homeSlug","prefix","slugs","origin","replace","alternates","locale","path","defaultLocalePath"],"mappings":"AAEA,SAASA,kBAAkB,EAAEC,gBAAgB,EAAEC,cAAc,QAAQ,mBAAkB;AAkBvF;;;;;;;;;;;;;;;;;;;;;;;CAuBC,GACD,OAAO,SAASC,wBAAwB,EACtCC,OAAO,EACPC,MAAM,EACNC,WAAW,MAAM,EACjBC,MAAM,EACNC,KAAK,EACa;IAClB,MAAMC,SAASL,SAASM,QAAQ,OAAO,OAAO;IAC9C,MAAMC,aAAqC,CAAC;IAE5C,KAAK,MAAMC,UAAUV,eAAeG,QAAS;QAC3C,MAAMQ,OAAOb,mBAAmB;YAAEK;YAAQC;YAAUM;YAAQL;YAAQC;QAAM;QAC1E,IAAIK,MAAM;YACRF,UAAU,CAACC,OAAO,GAAG,GAAGH,SAASI,MAAM;QACzC;IACF;IAEA,+EAA+E;IAC/E,6EAA6E;IAC7E,8EAA8E;IAC9E,6EAA6E;IAC7E,2EAA2E;IAC3E,MAAMC,oBAAoBH,UAAU,CAACV,iBAAiBI,QAAQ;IAC9D,IAAIS,mBAAmB;QACrBH,UAAU,CAAC,YAAY,GAAGG;IAC5B;IAEA,OAAOH;AACT"}
\ No newline at end of file
diff --git a/dist/modules/seo/index.d.ts b/dist/modules/seo/index.d.ts
index fd06c04..5072e81 100644
--- a/dist/modules/seo/index.d.ts
+++ b/dist/modules/seo/index.d.ts
@@ -1,5 +1,6 @@
export { buildAutoFillMetaHook } from './autoFillMeta.js';
export type { AutoFillMapping } from './autoFillMeta.js';
+export { buildBreadcrumbJsonLd } from './buildBreadcrumbJsonLd.js';
export { buildIconsMetadata } from './buildIconsMetadata.js';
export { buildMetadata } from './buildMetadata.js';
export type { PageMetadata } from './buildMetadata.js';
@@ -8,6 +9,8 @@ export { buildRobots } from './buildRobots.js';
export type { RobotsRules } from './buildRobots.js';
export { buildSitemapEntries } from './buildSitemapEntries.js';
export type { SitemapEntry } from './buildSitemapEntries.js';
+export { buildSiteNavigationJsonLd } from './buildSiteNavigationJsonLd.js';
+export { buildWebSiteJsonLd } from './buildWebSiteJsonLd.js';
export { composeTitle } from './composeTitle.js';
export type { TitleOrder } from './composeTitle.js';
export { createMetadataGenerator } from './createMetadataGenerator.js';
diff --git a/dist/modules/seo/index.js b/dist/modules/seo/index.js
index d07a4ec..9b1e53d 100644
--- a/dist/modules/seo/index.js
+++ b/dist/modules/seo/index.js
@@ -1,9 +1,12 @@
export { buildAutoFillMetaHook } from './autoFillMeta.js';
+export { buildBreadcrumbJsonLd } from './buildBreadcrumbJsonLd.js';
export { buildIconsMetadata } from './buildIconsMetadata.js';
export { buildMetadata } from './buildMetadata.js';
export { buildOrganizationJsonLd } from './buildOrganizationJsonLd.js';
export { buildRobots } from './buildRobots.js';
export { buildSitemapEntries } from './buildSitemapEntries.js';
+export { buildSiteNavigationJsonLd } from './buildSiteNavigationJsonLd.js';
+export { buildWebSiteJsonLd } from './buildWebSiteJsonLd.js';
export { composeTitle } from './composeTitle.js';
export { createMetadataGenerator } from './createMetadataGenerator.js';
export { createPageMetadata } from './createPageMetadata.js';
diff --git a/dist/modules/seo/index.js.map b/dist/modules/seo/index.js.map
index d96211c..4a74dd8 100644
--- a/dist/modules/seo/index.js.map
+++ b/dist/modules/seo/index.js.map
@@ -1 +1 @@
-{"version":3,"sources":["../../../src/modules/seo/index.ts"],"sourcesContent":["export { buildAutoFillMetaHook } from './autoFillMeta.js'\nexport type { AutoFillMapping } from './autoFillMeta.js'\nexport { buildIconsMetadata } from './buildIconsMetadata.js'\nexport { buildMetadata } from './buildMetadata.js'\nexport type { PageMetadata } from './buildMetadata.js'\nexport { buildOrganizationJsonLd } from './buildOrganizationJsonLd.js'\nexport { buildRobots } from './buildRobots.js'\nexport type { RobotsRules } from './buildRobots.js'\nexport { buildSitemapEntries } from './buildSitemapEntries.js'\nexport type { SitemapEntry } from './buildSitemapEntries.js'\nexport { composeTitle } from './composeTitle.js'\nexport type { TitleOrder } from './composeTitle.js'\nexport { createMetadataGenerator } from './createMetadataGenerator.js'\nexport { createPageMetadata } from './createPageMetadata.js'\nexport { buildHreflangAlternates } from './hreflang.js'\nexport { injectAutoFillMeta } from './injectAutoFillMeta.js'\nexport { injectSeoTabs } from './injectSeoTabs.js'\nexport { readSiteMetaConfig } from './readSiteMetaConfig.js'\nexport type { SiteMetaConfig } from './readSiteMetaConfig.js'\nexport { buildSeoPlugin } from './seoPluginConfig.js'\nexport { slugsAcrossLocales } from './slugsAcrossLocales.js'\nexport type { SeoMeta, SeoOption } from './types.js'\nexport { validateFaviconField } from './validateFavicon.js'\n"],"names":["buildAutoFillMetaHook","buildIconsMetadata","buildMetadata","buildOrganizationJsonLd","buildRobots","buildSitemapEntries","composeTitle","createMetadataGenerator","createPageMetadata","buildHreflangAlternates","injectAutoFillMeta","injectSeoTabs","readSiteMetaConfig","buildSeoPlugin","slugsAcrossLocales","validateFaviconField"],"mappings":"AAAA,SAASA,qBAAqB,QAAQ,oBAAmB;AAEzD,SAASC,kBAAkB,QAAQ,0BAAyB;AAC5D,SAASC,aAAa,QAAQ,qBAAoB;AAElD,SAASC,uBAAuB,QAAQ,+BAA8B;AACtE,SAASC,WAAW,QAAQ,mBAAkB;AAE9C,SAASC,mBAAmB,QAAQ,2BAA0B;AAE9D,SAASC,YAAY,QAAQ,oBAAmB;AAEhD,SAASC,uBAAuB,QAAQ,+BAA8B;AACtE,SAASC,kBAAkB,QAAQ,0BAAyB;AAC5D,SAASC,uBAAuB,QAAQ,gBAAe;AACvD,SAASC,kBAAkB,QAAQ,0BAAyB;AAC5D,SAASC,aAAa,QAAQ,qBAAoB;AAClD,SAASC,kBAAkB,QAAQ,0BAAyB;AAE5D,SAASC,cAAc,QAAQ,uBAAsB;AACrD,SAASC,kBAAkB,QAAQ,0BAAyB;AAE5D,SAASC,oBAAoB,QAAQ,uBAAsB"}
\ No newline at end of file
+{"version":3,"sources":["../../../src/modules/seo/index.ts"],"sourcesContent":["export { buildAutoFillMetaHook } from './autoFillMeta.js'\nexport type { AutoFillMapping } from './autoFillMeta.js'\nexport { buildBreadcrumbJsonLd } from './buildBreadcrumbJsonLd.js'\nexport { buildIconsMetadata } from './buildIconsMetadata.js'\nexport { buildMetadata } from './buildMetadata.js'\nexport type { PageMetadata } from './buildMetadata.js'\nexport { buildOrganizationJsonLd } from './buildOrganizationJsonLd.js'\nexport { buildRobots } from './buildRobots.js'\nexport type { RobotsRules } from './buildRobots.js'\nexport { buildSitemapEntries } from './buildSitemapEntries.js'\nexport type { SitemapEntry } from './buildSitemapEntries.js'\nexport { buildSiteNavigationJsonLd } from './buildSiteNavigationJsonLd.js'\nexport { buildWebSiteJsonLd } from './buildWebSiteJsonLd.js'\nexport { composeTitle } from './composeTitle.js'\nexport type { TitleOrder } from './composeTitle.js'\nexport { createMetadataGenerator } from './createMetadataGenerator.js'\nexport { createPageMetadata } from './createPageMetadata.js'\nexport { buildHreflangAlternates } from './hreflang.js'\nexport { injectAutoFillMeta } from './injectAutoFillMeta.js'\nexport { injectSeoTabs } from './injectSeoTabs.js'\nexport { readSiteMetaConfig } from './readSiteMetaConfig.js'\nexport type { SiteMetaConfig } from './readSiteMetaConfig.js'\nexport { buildSeoPlugin } from './seoPluginConfig.js'\nexport { slugsAcrossLocales } from './slugsAcrossLocales.js'\nexport type { SeoMeta, SeoOption } from './types.js'\nexport { validateFaviconField } from './validateFavicon.js'\n"],"names":["buildAutoFillMetaHook","buildBreadcrumbJsonLd","buildIconsMetadata","buildMetadata","buildOrganizationJsonLd","buildRobots","buildSitemapEntries","buildSiteNavigationJsonLd","buildWebSiteJsonLd","composeTitle","createMetadataGenerator","createPageMetadata","buildHreflangAlternates","injectAutoFillMeta","injectSeoTabs","readSiteMetaConfig","buildSeoPlugin","slugsAcrossLocales","validateFaviconField"],"mappings":"AAAA,SAASA,qBAAqB,QAAQ,oBAAmB;AAEzD,SAASC,qBAAqB,QAAQ,6BAA4B;AAClE,SAASC,kBAAkB,QAAQ,0BAAyB;AAC5D,SAASC,aAAa,QAAQ,qBAAoB;AAElD,SAASC,uBAAuB,QAAQ,+BAA8B;AACtE,SAASC,WAAW,QAAQ,mBAAkB;AAE9C,SAASC,mBAAmB,QAAQ,2BAA0B;AAE9D,SAASC,yBAAyB,QAAQ,iCAAgC;AAC1E,SAASC,kBAAkB,QAAQ,0BAAyB;AAC5D,SAASC,YAAY,QAAQ,oBAAmB;AAEhD,SAASC,uBAAuB,QAAQ,+BAA8B;AACtE,SAASC,kBAAkB,QAAQ,0BAAyB;AAC5D,SAASC,uBAAuB,QAAQ,gBAAe;AACvD,SAASC,kBAAkB,QAAQ,0BAAyB;AAC5D,SAASC,aAAa,QAAQ,qBAAoB;AAClD,SAASC,kBAAkB,QAAQ,0BAAyB;AAE5D,SAASC,cAAc,QAAQ,uBAAsB;AACrD,SAASC,kBAAkB,QAAQ,0BAAyB;AAE5D,SAASC,oBAAoB,QAAQ,uBAAsB"}
\ No newline at end of file
diff --git a/docs/seo.md b/docs/seo.md
index 243f256..aabb63f 100644
--- a/docs/seo.md
+++ b/docs/seo.md
@@ -254,6 +254,19 @@ części — dodasz, gdyby realnie było ich tyle. Jedna sitemapa wystarcza do ~
Favicon i structured data wpływają na to, jak strona wygląda w wynikach Google.
Plugin generuje jedno i drugie z panelu — projekt tylko wpina w root layout.
+### Favicon — format: PNG, nie SVG (ważne)
+
+**Dla Google użyj PNG (≥48×48), nie SVG.** Zweryfikowane: Google niezawodnie
+wspiera PNG i ICO, ale **SVG w wynikach Google jest zawodny** — często pokazuje
+glob mimo że w karcie przeglądarki favicon renderuje się dobrze. Oficjalna
+dokumentacja Google nie wymienia SVG. Jeśli zależy Ci na faviconie w wyszukiwarce
+— wgraj PNG.
+
+- **PNG ≥48×48** (idealnie 96 lub 192), kwadratowy → działa w Google ✓
+- **SVG** → działa w przeglądarce, ale w Google glob (zawodne) ✗
+- Walidacja pola favicon OSTRZEGA, gdy wgrasz SVG (żebyś wiedział, że dla search
+ potrzebny PNG).
+
### Favicon — dlaczego się nie pokazywał
Google ma twarde wymogi: `` w ``, kwadratowy, **≥48×48px**,
@@ -326,4 +339,201 @@ favicon URL jest publiczny (nie za auth).
z poprawnym URL.
2. Otwórz sam URL favicon w przeglądarce — obraz się pokazuje, ≥48×48.
3. Rich Results Test (Google) — wklej URL strony, sprawdź Organization.
-4. Search Console → poproś o ponowne indeksowanie strony głównej.
\ No newline at end of file
+4. Search Console → poproś o ponowne indeksowanie strony głównej.
+
+## Ręczne rozszerzenia SEO/PWA (manifest itp.) — z panelu, NIE hardkod
+
+Niektóre rzeczy SEO/PWA są na tyle projekt-specyficzne i jednorazowe, że plugin
+ich nie dostarcza (byłoby przeinżynierowaniem). Robisz je w projekcie — ALE
+poprawnie: czytając z panelu/env, nie zaszywając wartości klienta.
+
+> **Zasada:** nawet gdy coś robisz ręcznie w projekcie, dane (nazwa, kolory,
+> opis, logo) czytaj z panelu (SiteSettings) albo env. Zaszyta nazwa/kolor
+> klienta = antywzorzec (patrz standardy-kodu.md). Manifest „R Custom Cars" z
+> hardkodem zadziała tylko dla jednego klienta.
+
+### Web App Manifest (PWA) — jak zrobić DOBRZE
+
+```ts
+// app/manifest.ts
+import type { MetadataRoute } from 'next'
+import { getCachedPayload } from '@/lib/content'
+import { getSiteSettings } from '@intecion/ipal-kit'
+import type { SiteSetting } from '@/payload-types'
+
+export default async function manifest(): Promise {
+ const payload = await getCachedPayload()
+ const settings = await getSiteSettings(payload, { locale: 'pl' as never })
+
+ // Wszystko z panelu — zero hardkodu. Ikona z pola logo/favicon (upload),
+ // nie ze statycznej ścieżki.
+ const iconUrl =
+ typeof settings.logo === 'object' && settings.logo?.url ? settings.logo.url : undefined
+
+ return {
+ name: settings.siteName ?? '',
+ short_name: settings.siteName ?? '', // albo osobne pole, jeśli dodasz
+ start_url: '/',
+ display: 'standalone',
+ ...(iconUrl
+ ? { icons: [{ src: iconUrl, sizes: 'any', type: 'image/svg+xml' }] }
+ : {}),
+ // description / theme_color / background_color:
+ // jeśli klient ich potrzebuje, DODAJ POLA w SiteSettings i czytaj stąd —
+ // NIE wpisuj '#d4af37' na sztywno. Bez pól — pomiń (manifest działa bez nich).
+ }
+}
+```
+
+**Kontrast — czego NIE robić** (realny błąd z sesji):
+
+```ts
+// ŹLE — wszystko zaszyte, zadziała tylko dla jednego klienta
+let name = 'R Custom Cars' // hardkod nazwy
+short_name: 'RCC', // hardkod
+description: 'Custom car styling...', // hardkod
+background_color: '#08080a', theme_color: '#d4af37', // hardkod kolorów
+icons: [{ src: '/logo/rcc-logo.svg' }] // statyczna ścieżka, nie panel
+```
+
+Jeśli klient potrzebuje kolorów motywu / opisu w manifeście — **dodaj pola**
+`themeColor`, `manifestDescription` w SiteSettings (SiteSettingsFields przez
+opcje pluginu) i czytaj z panelu. Wtedy redaktor je zmienia, i nie są zaszyte.
+
+### Inne ręczne rozszerzenia — ta sama zasada
+
+Cokolwiek dodajesz ręcznie (dodatkowe meta tagi, structured data konkretnego
+typu, itp.):
+- dane z panelu (SiteSettings / pola strony) albo env
+- nic zaszytego per klient (nazwa, kolor, adres, domena)
+- jeśli to uniwersalne i powtarzalne → rozważ zgłoszenie do pluginu zamiast
+ ręcznie (patrz ANTIGRAVITY-ZASADY-AGENT.md A0)
+
+## SEO wielojęzyczne — hreflang, x-default, redirect roota
+
+Przekierowanie `/` → `/pl` (negocjacja locale) może wpływać na SEO. Kluczowe:
+plugin generuje hreflang, więc Google rozumie, że `/pl` i `/en` to wersje
+językowe (nie duplikaty). Ale są niuanse.
+
+### hreflang + x-default (generowane przez plugin)
+
+`buildHreflangAlternates` generuje `alternates.languages` z wpisami per locale
+ORAZ **`x-default`** wskazujący na defaultLocale. x-default mówi Google: „gdy
+język/region użytkownika nie pasuje do żadnej wersji, użyj TEJ" — co pokrywa
+sytuację roota (Googlebot bez preferencji językowej). Bez x-default Google
+zgadywałby; z nim dostaje jasną wskazówkę (domyślnie pl).
+
+Działa automatycznie przez createPageMetadata (canonical + hreflang + x-default).
+
+### Redirect roota — na co uważać
+
+- **307 (temporary)** na `/` → `/pl` — plugin tak robi. Dla warunkowego redirectu
+ (zależnego od negocjacji) to obronne. Google i tak podąża.
+- **Negocjacja Accept-Language** — Googlebot bywa z `Accept-Language: en` albo
+ bez. Może trafić na `/en`. x-default (→ pl) łagodzi to: Google wie, że
+ domyślna wersja to polska.
+- **Root nie ma własnej treści** — cała moc idzie przez redirect na locale. To
+ normalne dla i18n stron, hreflang to obsługuje.
+
+### Weryfikacja SEO wielojęzycznego
+
+1. Search Console → Inspekcja URL dla `/` — zobacz, na co Google przekierowuje
+ i co indeksuje.
+2. Sprawdź, czy `/pl` i `/en` są indeksowane osobno (nie jako duplikaty).
+3. Rich Results / źródło strony → potwierdź ``
+ z wpisami per locale + `hreflang="x-default"`.
+4. Search Console → raport Międzynarodowe targetowanie (jeśli dostępny) — błędy
+ hreflang.
+
+### Częste błędy (nie rób tak)
+
+- Brak hreflang → Google traktuje wersje jako duplikaty (plugin to ma, nie usuwaj).
+- Zaszyta mapa ścieżek zamiast getLocalizedSlugs → hreflang się rozjedzie z bazą.
+- `noindex` na `/pl` przez pomyłkę → wypada z indeksu. Sprawdź robots meta.
+- Redirect roota na twardo 301 do jednego języka → tracisz negocjację i drugą
+ wersję. Zostaw negocjację + hreflang.
+
+## Sitelinks i structured data (branding w wynikach Google)
+
+Cel: żeby wyszukanie marki („rcustomcars") pokazało stronę główną + podlinki
+(sitelinks) z opisami. Ważne — **sitelinków NIE DA SIĘ wymusić.** Google
+generuje je algorytmicznie ze struktury strony, linkowania wewnętrznego, jasnych
+tytułów i rankingu. Żaden kod ich nie włączy. Plugin dostarcza SYGNAŁY, które
+zwiększają szansę — nie gwarancję.
+
+### Co realnie wpływa na sitelinki (kolejność wg wagi)
+
+1. **Ranking na 1. stronie Google** — bez tego sitelinków nie ma. To robota SEO
+ (treść, linki), nie kodu.
+2. **Czysta struktura + jasne tytuły** — logiczna hierarchia stron, opisowe title
+ (nie „Strona 1"). Patrz fundamenty-projektu.md.
+3. **Linkowanie wewnętrzne** — ważne strony podlinkowane z głównej.
+4. **Structured data** (poniżej) — sygnał pomocniczy, nie przełącznik.
+5. **Sitemap + robots** — żeby Google w ogóle widział wszystkie strony (patrz
+ niżej — to fundament, sprawdź czy działa!).
+
+### Structured data z pluginu — 3 helpery
+
+Wszystkie emitowane jako `