From 068415849f9c3be6e63a3697b3116425d5ff09b7 Mon Sep 17 00:00:00 2001 From: rasm-its Date: Tue, 8 Sep 2026 12:49:49 +0200 Subject: [PATCH] 1.2.0: local SEO structured data (LocalBusiness, Service, FAQPage), noindex per page --- dist/index.d.ts | 2 +- dist/index.js | 2 +- dist/index.js.map | 2 +- .../frontend/createContentHelpers.d.ts | 32 +-- dist/modules/frontend/createContentHelpers.js | 64 +++-- .../frontend/createContentHelpers.js.map | 2 +- dist/modules/seo/buildFaqJsonLd.d.ts | 33 +++ dist/modules/seo/buildFaqJsonLd.js | 34 +++ dist/modules/seo/buildFaqJsonLd.js.map | 1 + .../modules/seo/buildLocalBusinessJsonLd.d.ts | 71 ++++++ dist/modules/seo/buildLocalBusinessJsonLd.js | 73 ++++++ .../seo/buildLocalBusinessJsonLd.js.map | 1 + dist/modules/seo/buildMetadata.d.ts | 4 + dist/modules/seo/buildMetadata.js | 6 + dist/modules/seo/buildMetadata.js.map | 2 +- dist/modules/seo/buildServiceJsonLd.d.ts | 39 +++ dist/modules/seo/buildServiceJsonLd.js | 35 +++ dist/modules/seo/buildServiceJsonLd.js.map | 1 + dist/modules/seo/index.d.ts | 3 + dist/modules/seo/index.js | 3 + dist/modules/seo/index.js.map | 2 +- dist/modules/seo/types.d.ts | 1 + dist/modules/seo/types.js.map | 2 +- docs/deployment.md | 206 ++++++++++++++++ docs/i18n.md | 15 ++ docs/seo.md | 222 ++++++++++++++++-- src/index.ts | 3 + src/modules/frontend/createContentHelpers.ts | 93 +++++--- src/modules/seo/buildFaqJsonLd.ts | 37 +++ src/modules/seo/buildLocalBusinessJsonLd.ts | 86 +++++++ src/modules/seo/buildMetadata.ts | 5 + src/modules/seo/buildServiceJsonLd.ts | 48 ++++ src/modules/seo/index.ts | 3 + src/modules/seo/types.ts | 1 + 34 files changed, 1030 insertions(+), 104 deletions(-) create mode 100644 dist/modules/seo/buildFaqJsonLd.d.ts create mode 100644 dist/modules/seo/buildFaqJsonLd.js create mode 100644 dist/modules/seo/buildFaqJsonLd.js.map create mode 100644 dist/modules/seo/buildLocalBusinessJsonLd.d.ts create mode 100644 dist/modules/seo/buildLocalBusinessJsonLd.js create mode 100644 dist/modules/seo/buildLocalBusinessJsonLd.js.map create mode 100644 dist/modules/seo/buildServiceJsonLd.d.ts create mode 100644 dist/modules/seo/buildServiceJsonLd.js create mode 100644 dist/modules/seo/buildServiceJsonLd.js.map create mode 100644 docs/deployment.md create mode 100644 src/modules/seo/buildFaqJsonLd.ts create mode 100644 src/modules/seo/buildLocalBusinessJsonLd.ts create mode 100644 src/modules/seo/buildServiceJsonLd.ts diff --git a/dist/index.d.ts b/dist/index.d.ts index 3d4b2b0..aeaa907 100644 --- a/dist/index.d.ts +++ b/dist/index.d.ts @@ -29,7 +29,7 @@ export type { GlobalQueryOptions } from './modules/payload/index.js'; export { getGlobal, getSiteIntegrations, getSiteSettings, SITE_INTEGRATIONS_SLUG, SITE_SETTINGS_SLUG, } from './modules/payload/index.js'; 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 { buildFaqJsonLd, buildIconsMetadata, buildLocalBusinessJsonLd, buildOrganizationJsonLd, buildServiceJsonLd, 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'; diff --git a/dist/index.js b/dist/index.js index 8245f36..346fda5 100644 --- a/dist/index.js +++ b/dist/index.js @@ -18,7 +18,7 @@ export { getNotificationTexts, NOTIFICATION_FALLBACK, resolveFormMessage } from export { ALL_SYSTEM_PAGE_ROLES, getSystemPagePath } from './modules/pages/index.js'; 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'; +export { buildFaqJsonLd, buildIconsMetadata, buildLocalBusinessJsonLd, buildOrganizationJsonLd, buildServiceJsonLd, 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'; diff --git a/dist/index.js.map b/dist/index.js.map index b8741f1..8364bf1 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'\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 +{"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 buildFaqJsonLd,\n buildIconsMetadata,\n buildLocalBusinessJsonLd,\n buildOrganizationJsonLd,\n buildServiceJsonLd,\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","buildFaqJsonLd","buildIconsMetadata","buildLocalBusinessJsonLd","buildOrganizationJsonLd","buildServiceJsonLd","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,cAAc,EACdC,kBAAkB,EAClBC,wBAAwB,EACxBC,uBAAuB,EACvBC,kBAAkB,EAClBC,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/frontend/createContentHelpers.d.ts b/dist/modules/frontend/createContentHelpers.d.ts index 47b2150..923b7f8 100644 --- a/dist/modules/frontend/createContentHelpers.d.ts +++ b/dist/modules/frontend/createContentHelpers.d.ts @@ -1,14 +1,8 @@ import type { BasePayload, SanitizedConfig } from 'payload'; -import type { ArchiveEntries, ContentOption, ResolvedRoute } from '../content/index.js'; +import type { ContentOption, ResolvedRoute, ArchiveEntries } from '../content/index.js'; import type { I18nConfig } from '../i18n/index.js'; -import type { RobotsRules, SitemapEntry } from '../seo/index.js'; +import type { SitemapEntry, RobotsRules } from '../seo/index.js'; type CreateContentHelpersArgs = { - /** - * Absolute site origin for sitemap/robots URLs. Falls back to - * NEXT_PUBLIC_SERVER_URL, then to a relative origin (which most crawlers - * reject, so set one in production). - */ - baseUrl?: string; /** * The client's payload config promise (the default export of payload.config). * Passed in because the plugin never imports the client's config directly. @@ -16,15 +10,21 @@ type CreateContentHelpersArgs = { config: Promise | SanitizedConfig; /** Archive-backed collections, same value as the plugin option. */ content?: ContentOption; + /** SiteSettings global slug. Defaults to 'site-settings'. */ + settingsSlug?: string; + /** Pages collection slug. Defaults to 'pages'. */ + pagesSlug?: string; /** * i18n config. Required only if you want the ready-made `sitemap` / `robots` * handlers — they need the locale list to emit hreflang. */ i18n?: I18nConfig; - /** Pages collection slug. Defaults to 'pages'. */ - pagesSlug?: string; - /** SiteSettings global slug. Defaults to 'site-settings'. */ - settingsSlug?: string; + /** + * Absolute site origin for sitemap/robots URLs. Falls back to + * NEXT_PUBLIC_SERVER_URL, then to a relative origin (which most crawlers + * reject, so set one in production). + */ + baseUrl?: string; }; /** * Bundles the per-request data helpers a frontend needs — the same cached @@ -52,13 +52,13 @@ type CreateContentHelpersArgs = { * a URL is, fetching gets the listing. Metadata generation needs the first and * not the second, and a page component composes them in two obvious lines. */ -export declare function createContentHelpers({ baseUrl, config, content, i18n, pagesSlug, settingsSlug, }: CreateContentHelpersArgs): { +export declare function createContentHelpers({ config, content, settingsSlug, pagesSlug, i18n, baseUrl, }: CreateContentHelpersArgs): { getCachedPayload: () => Promise; getConfiguredLocales: () => Promise; - getEntries: (collection: string, locale: string, page: number, perPage: number) => Promise; getSettings: (locale: string) => Promise; - resolveRoute: (locale: string, segments: string[] | undefined, page: number) => Promise; - robots: () => RobotsRules; + resolveRoute: (locale: string, segments: string[] | undefined, page: number) => Promise; + getEntries: (collection: string, locale: string, page: number, perPage: number) => Promise; sitemap: () => Promise; + robots: () => RobotsRules; }; export {}; diff --git a/dist/modules/frontend/createContentHelpers.js b/dist/modules/frontend/createContentHelpers.js index 1342ee6..24d7f6e 100644 --- a/dist/modules/frontend/createContentHelpers.js +++ b/dist/modules/frontend/createContentHelpers.js @@ -1,7 +1,7 @@ -import { getPayload } from 'payload'; import { cache } from 'react'; -import { getArchiveEntries, resolveRoute as resolveRouteRaw } from '../content/index.js'; -import { buildRobots, buildSitemapEntries } from '../seo/index.js'; +import { getPayload } from 'payload'; +import { resolveRoute as resolveRouteRaw, getArchiveEntries } from '../content/index.js'; +import { buildSitemapEntries, buildRobots } from '../seo/index.js'; /** * Bundles the per-request data helpers a frontend needs — the same cached * wrappers every project was writing by hand (getPayload, settings, locale @@ -27,7 +27,7 @@ import { buildRobots, buildSitemapEntries } from '../seo/index.js'; * `resolveRoute` and `getEntries` are separate on purpose: routing decides what * a URL is, fetching gets the listing. Metadata generation needs the first and * not the second, and a page component composes them in two obvious lines. - */ export function createContentHelpers({ baseUrl, config, content, i18n, pagesSlug = 'pages', settingsSlug = 'site-settings' }) { + */ export function createContentHelpers({ config, content, settingsSlug = 'site-settings', pagesSlug = 'pages', i18n, baseUrl }) { const origin = baseUrl ?? process.env.NEXT_PUBLIC_SERVER_URL ?? ''; const getCachedPayload = cache(async ()=>getPayload({ config: await config @@ -40,29 +40,29 @@ import { buildRobots, buildSitemapEntries } from '../seo/index.js'; const payload = await getCachedPayload(); return payload.findGlobal({ slug: settingsSlug, - depth: 2, - locale: locale + locale: locale, + depth: 2 }); }); /** What does this URL point at? Routing only — no listing data. */ const resolveRoute = cache(async (locale, segments, page)=>{ const payload = await getCachedPayload(); return resolveRouteRaw({ - content, - locale, - page, - pagesSlug, payload, + locale, segments, + page, + content, + pagesSlug, settingsSlug }); }); /** One page of a collection's entries, for an archive listing. */ const getEntries = cache(async (collection, locale, page, perPage)=>{ const payload = await getCachedPayload(); return getArchiveEntries({ + payload, collection, locale, page, - payload, perPage }); }); @@ -73,19 +73,39 @@ import { buildRobots, buildSitemapEntries } from '../seo/index.js'; * ```ts * // app/sitemap.ts * export { sitemap as default } from '@/lib/content' + * export const dynamic = 'force-dynamic' // generate at runtime, not build * ``` + * + * IMPORTANT — container deploys (Coolify/Docker/Railway/CI): Next treats + * sitemap.ts as STATIC by default and prerenders it during `next build`, which + * calls into Payload → the database. The build container usually has no access + * to the internal Docker network, so the DB connection fails (ENOTFOUND) and + * the build dies. Two defenses: + * 1. `export const dynamic = 'force-dynamic'` in app/sitemap.ts — skips build + * prerender, generates at runtime when the DB is reachable (recommended). + * 2. This handler also catches DB errors and returns [] so that even without + * (1) the build won't crash — it just ships an empty sitemap until the + * next runtime regeneration. (1) is still preferred; (2) is a safety net. */ const sitemap = cache(async ()=>{ if (!i18n) { throw new Error('[ipal] createContentHelpers: pass `i18n` to use the sitemap handler.'); } - return buildSitemapEntries({ - baseUrl: origin, - config: i18n, - content, - pagesSlug, - payload: await getCachedPayload(), - settingsSlug - }); + try { + return await buildSitemapEntries({ + payload: await getCachedPayload(), + config: i18n, + baseUrl: origin, + content, + pagesSlug, + settingsSlug + }); + } catch (error) { + // DB unreachable (typically a container build with no DB network) — return + // an empty sitemap instead of failing the build. Runtime regeneration will + // produce the real one once the DB is reachable. See dynamic='force-dynamic'. + console.warn('[ipal] sitemap: could not reach the database, returning empty entries ' + "(add `export const dynamic = 'force-dynamic'` to app/sitemap.ts to " + 'generate at runtime and avoid build-time DB access):', error); + return []; + } }); /** * Ready-made handler for Next's `app/robots.ts`. Re-export directly: @@ -100,11 +120,11 @@ import { buildRobots, buildSitemapEntries } from '../seo/index.js'; return { getCachedPayload, getConfiguredLocales, - getEntries, getSettings, resolveRoute, - robots, - sitemap + getEntries, + sitemap, + robots }; } diff --git a/dist/modules/frontend/createContentHelpers.js.map b/dist/modules/frontend/createContentHelpers.js.map index c0b4c3d..f21b217 100644 --- a/dist/modules/frontend/createContentHelpers.js.map +++ b/dist/modules/frontend/createContentHelpers.js.map @@ -1 +1 @@ -{"version":3,"sources":["../../../src/modules/frontend/createContentHelpers.ts"],"sourcesContent":["import type { BasePayload, SanitizedConfig } from 'payload'\n\nimport { getPayload } from 'payload'\nimport { cache } from 'react'\n\nimport type { ArchiveEntries, ContentOption, ResolvedRoute } from '../content/index.js'\nimport type { I18nConfig } from '../i18n/index.js'\nimport type { RobotsRules, SitemapEntry } from '../seo/index.js'\n\nimport { getArchiveEntries, resolveRoute as resolveRouteRaw } from '../content/index.js'\nimport { buildRobots, buildSitemapEntries } from '../seo/index.js'\n\ntype CreateContentHelpersArgs = {\n /**\n * Absolute site origin for sitemap/robots URLs. Falls back to\n * NEXT_PUBLIC_SERVER_URL, then to a relative origin (which most crawlers\n * reject, so set one in production).\n */\n baseUrl?: string\n /**\n * The client's payload config promise (the default export of payload.config).\n * Passed in because the plugin never imports the client's config directly.\n */\n config: Promise | SanitizedConfig\n /** Archive-backed collections, same value as the plugin option. */\n content?: ContentOption\n /**\n * i18n config. Required only if you want the ready-made `sitemap` / `robots`\n * handlers — they need the locale list to emit hreflang.\n */\n i18n?: I18nConfig\n /** Pages collection slug. Defaults to 'pages'. */\n pagesSlug?: string\n /** SiteSettings global slug. Defaults to 'site-settings'. */\n settingsSlug?: string\n}\n\n/**\n * Bundles the per-request data helpers a frontend needs — the same cached\n * wrappers every project was writing by hand (getPayload, settings, locale\n * list, route resolution, archive entries).\n *\n * Everything is wrapped in React `cache()`, so within one request a value is\n * fetched once no matter how many times it's asked for — which matters because\n * Next runs generateMetadata and the page component separately, and both hit\n * these. Crucially the Payload instance is cached *here*, once, so every helper\n * shares it; that's why this is a factory and not loose functions importing a\n * shared module.\n *\n * ```ts\n * // src/lib/content.ts\n * import { createContentHelpers } from 'ipal-kit'\n * import config from '@/payload.config'\n * import { contentConfig } from '@/content.config'\n *\n * export const { getCachedPayload, getSettings, getConfiguredLocales, resolveRoute, getEntries } =\n * createContentHelpers({ config, content: contentConfig })\n * ```\n *\n * `resolveRoute` and `getEntries` are separate on purpose: routing decides what\n * a URL is, fetching gets the listing. Metadata generation needs the first and\n * not the second, and a page component composes them in two obvious lines.\n */\nexport function createContentHelpers({\n baseUrl,\n config,\n content,\n i18n,\n pagesSlug = 'pages',\n settingsSlug = 'site-settings',\n}: CreateContentHelpersArgs) {\n const origin = baseUrl ?? process.env.NEXT_PUBLIC_SERVER_URL ?? ''\n const getCachedPayload = cache(async (): Promise =>\n getPayload({ config: await config }),\n )\n\n const getConfiguredLocales = cache(async (): Promise => {\n const c = await config\n return c.localization ? c.localization.locales.map((l) => l.code) : []\n })\n\n const getSettings = cache(async (locale: string) => {\n const payload = await getCachedPayload()\n return payload.findGlobal({ slug: settingsSlug as never, depth: 2, locale: locale as never })\n })\n\n /** What does this URL point at? Routing only — no listing data. */\n const resolveRoute = cache(\n async (\n locale: string,\n segments: string[] | undefined,\n page: number,\n ): Promise => {\n const payload = await getCachedPayload()\n return resolveRouteRaw({ content, locale, page, pagesSlug, payload, segments, settingsSlug })\n },\n )\n\n /** One page of a collection's entries, for an archive listing. */\n const getEntries = cache(\n async (\n collection: string,\n locale: string,\n page: number,\n perPage: number,\n ): Promise => {\n const payload = await getCachedPayload()\n return getArchiveEntries({ collection, locale, page, payload, perPage })\n },\n )\n\n /**\n * Ready-made handler for Next's `app/sitemap.ts` — every page and entry with\n * per-URL hreflang and lastmod. Re-export it directly:\n *\n * ```ts\n * // app/sitemap.ts\n * export { sitemap as default } from '@/lib/content'\n * ```\n */\n const sitemap = cache(async (): Promise => {\n if (!i18n) {\n throw new Error('[ipal] createContentHelpers: pass `i18n` to use the sitemap handler.')\n }\n return buildSitemapEntries({\n baseUrl: origin,\n config: i18n,\n content,\n pagesSlug,\n payload: await getCachedPayload(),\n settingsSlug,\n })\n })\n\n /**\n * Ready-made handler for Next's `app/robots.ts`. Re-export directly:\n *\n * ```ts\n * // app/robots.ts\n * export { robots as default } from '@/lib/content'\n * ```\n */\n const robots = (): RobotsRules => buildRobots({ baseUrl: origin })\n\n return {\n getCachedPayload,\n getConfiguredLocales,\n getEntries,\n getSettings,\n resolveRoute,\n robots,\n sitemap,\n }\n}\n"],"names":["getPayload","cache","getArchiveEntries","resolveRoute","resolveRouteRaw","buildRobots","buildSitemapEntries","createContentHelpers","baseUrl","config","content","i18n","pagesSlug","settingsSlug","origin","process","env","NEXT_PUBLIC_SERVER_URL","getCachedPayload","getConfiguredLocales","c","localization","locales","map","l","code","getSettings","locale","payload","findGlobal","slug","depth","segments","page","getEntries","collection","perPage","sitemap","Error","robots"],"mappings":"AAEA,SAASA,UAAU,QAAQ,UAAS;AACpC,SAASC,KAAK,QAAQ,QAAO;AAM7B,SAASC,iBAAiB,EAAEC,gBAAgBC,eAAe,QAAQ,sBAAqB;AACxF,SAASC,WAAW,EAAEC,mBAAmB,QAAQ,kBAAiB;AA2BlE;;;;;;;;;;;;;;;;;;;;;;;;;CAyBC,GACD,OAAO,SAASC,qBAAqB,EACnCC,OAAO,EACPC,MAAM,EACNC,OAAO,EACPC,IAAI,EACJC,YAAY,OAAO,EACnBC,eAAe,eAAe,EACL;IACzB,MAAMC,SAASN,WAAWO,QAAQC,GAAG,CAACC,sBAAsB,IAAI;IAChE,MAAMC,mBAAmBjB,MAAM,UAC7BD,WAAW;YAAES,QAAQ,MAAMA;QAAO;IAGpC,MAAMU,uBAAuBlB,MAAM;QACjC,MAAMmB,IAAI,MAAMX;QAChB,OAAOW,EAAEC,YAAY,GAAGD,EAAEC,YAAY,CAACC,OAAO,CAACC,GAAG,CAAC,CAACC,IAAMA,EAAEC,IAAI,IAAI,EAAE;IACxE;IAEA,MAAMC,cAAczB,MAAM,OAAO0B;QAC/B,MAAMC,UAAU,MAAMV;QACtB,OAAOU,QAAQC,UAAU,CAAC;YAAEC,MAAMjB;YAAuBkB,OAAO;YAAGJ,QAAQA;QAAgB;IAC7F;IAEA,iEAAiE,GACjE,MAAMxB,eAAeF,MACnB,OACE0B,QACAK,UACAC;QAEA,MAAML,UAAU,MAAMV;QACtB,OAAOd,gBAAgB;YAAEM;YAASiB;YAAQM;YAAMrB;YAAWgB;YAASI;YAAUnB;QAAa;IAC7F;IAGF,gEAAgE,GAChE,MAAMqB,aAAajC,MACjB,OACEkC,YACAR,QACAM,MACAG;QAEA,MAAMR,UAAU,MAAMV;QACtB,OAAOhB,kBAAkB;YAAEiC;YAAYR;YAAQM;YAAML;YAASQ;QAAQ;IACxE;IAGF;;;;;;;;GAQC,GACD,MAAMC,UAAUpC,MAAM;QACpB,IAAI,CAACU,MAAM;YACT,MAAM,IAAI2B,MAAM;QAClB;QACA,OAAOhC,oBAAoB;YACzBE,SAASM;YACTL,QAAQE;YACRD;YACAE;YACAgB,SAAS,MAAMV;YACfL;QACF;IACF;IAEA;;;;;;;GAOC,GACD,MAAM0B,SAAS,IAAmBlC,YAAY;YAAEG,SAASM;QAAO;IAEhE,OAAO;QACLI;QACAC;QACAe;QACAR;QACAvB;QACAoC;QACAF;IACF;AACF"} \ No newline at end of file +{"version":3,"sources":["../../../src/modules/frontend/createContentHelpers.ts"],"sourcesContent":["import { cache } from 'react'\nimport type { BasePayload, SanitizedConfig } from 'payload'\nimport { getPayload } from 'payload'\n\nimport type { ContentOption, ResolvedRoute, ArchiveEntries } from '../content/index.js'\nimport { resolveRoute as resolveRouteRaw, getArchiveEntries } from '../content/index.js'\nimport type { I18nConfig } from '../i18n/index.js'\nimport type { SitemapEntry, RobotsRules } from '../seo/index.js'\nimport { buildSitemapEntries, buildRobots } from '../seo/index.js'\n\ntype CreateContentHelpersArgs = {\n /**\n * The client's payload config promise (the default export of payload.config).\n * Passed in because the plugin never imports the client's config directly.\n */\n config: Promise | SanitizedConfig\n /** Archive-backed collections, same value as the plugin option. */\n content?: ContentOption\n /** SiteSettings global slug. Defaults to 'site-settings'. */\n settingsSlug?: string\n /** Pages collection slug. Defaults to 'pages'. */\n pagesSlug?: string\n /**\n * i18n config. Required only if you want the ready-made `sitemap` / `robots`\n * handlers — they need the locale list to emit hreflang.\n */\n i18n?: I18nConfig\n /**\n * Absolute site origin for sitemap/robots URLs. Falls back to\n * NEXT_PUBLIC_SERVER_URL, then to a relative origin (which most crawlers\n * reject, so set one in production).\n */\n baseUrl?: string\n}\n\n/**\n * Bundles the per-request data helpers a frontend needs — the same cached\n * wrappers every project was writing by hand (getPayload, settings, locale\n * list, route resolution, archive entries).\n *\n * Everything is wrapped in React `cache()`, so within one request a value is\n * fetched once no matter how many times it's asked for — which matters because\n * Next runs generateMetadata and the page component separately, and both hit\n * these. Crucially the Payload instance is cached *here*, once, so every helper\n * shares it; that's why this is a factory and not loose functions importing a\n * shared module.\n *\n * ```ts\n * // src/lib/content.ts\n * import { createContentHelpers } from 'ipal-kit'\n * import config from '@/payload.config'\n * import { contentConfig } from '@/content.config'\n *\n * export const { getCachedPayload, getSettings, getConfiguredLocales, resolveRoute, getEntries } =\n * createContentHelpers({ config, content: contentConfig })\n * ```\n *\n * `resolveRoute` and `getEntries` are separate on purpose: routing decides what\n * a URL is, fetching gets the listing. Metadata generation needs the first and\n * not the second, and a page component composes them in two obvious lines.\n */\nexport function createContentHelpers({\n config,\n content,\n settingsSlug = 'site-settings',\n pagesSlug = 'pages',\n i18n,\n baseUrl,\n}: CreateContentHelpersArgs) {\n const origin = baseUrl ?? process.env.NEXT_PUBLIC_SERVER_URL ?? ''\n const getCachedPayload = cache(async (): Promise =>\n getPayload({ config: await config }),\n )\n\n const getConfiguredLocales = cache(async (): Promise => {\n const c = await config\n return c.localization ? c.localization.locales.map((l) => l.code) : []\n })\n\n const getSettings = cache(async (locale: string) => {\n const payload = await getCachedPayload()\n return payload.findGlobal({ slug: settingsSlug as never, locale: locale as never, depth: 2 })\n })\n\n /** What does this URL point at? Routing only — no listing data. */\n const resolveRoute = cache(\n async (\n locale: string,\n segments: string[] | undefined,\n page: number,\n ): Promise => {\n const payload = await getCachedPayload()\n return resolveRouteRaw({ payload, locale, segments, page, content, pagesSlug, settingsSlug })\n },\n )\n\n /** One page of a collection's entries, for an archive listing. */\n const getEntries = cache(\n async (\n collection: string,\n locale: string,\n page: number,\n perPage: number,\n ): Promise => {\n const payload = await getCachedPayload()\n return getArchiveEntries({ payload, collection, locale, page, perPage })\n },\n )\n\n /**\n * Ready-made handler for Next's `app/sitemap.ts` — every page and entry with\n * per-URL hreflang and lastmod. Re-export it directly:\n *\n * ```ts\n * // app/sitemap.ts\n * export { sitemap as default } from '@/lib/content'\n * export const dynamic = 'force-dynamic' // generate at runtime, not build\n * ```\n *\n * IMPORTANT — container deploys (Coolify/Docker/Railway/CI): Next treats\n * sitemap.ts as STATIC by default and prerenders it during `next build`, which\n * calls into Payload → the database. The build container usually has no access\n * to the internal Docker network, so the DB connection fails (ENOTFOUND) and\n * the build dies. Two defenses:\n * 1. `export const dynamic = 'force-dynamic'` in app/sitemap.ts — skips build\n * prerender, generates at runtime when the DB is reachable (recommended).\n * 2. This handler also catches DB errors and returns [] so that even without\n * (1) the build won't crash — it just ships an empty sitemap until the\n * next runtime regeneration. (1) is still preferred; (2) is a safety net.\n */\n const sitemap = cache(async (): Promise => {\n if (!i18n) {\n throw new Error('[ipal] createContentHelpers: pass `i18n` to use the sitemap handler.')\n }\n try {\n return await buildSitemapEntries({\n payload: await getCachedPayload(),\n config: i18n,\n baseUrl: origin,\n content,\n pagesSlug,\n settingsSlug,\n })\n } catch (error) {\n // DB unreachable (typically a container build with no DB network) — return\n // an empty sitemap instead of failing the build. Runtime regeneration will\n // produce the real one once the DB is reachable. See dynamic='force-dynamic'.\n console.warn(\n '[ipal] sitemap: could not reach the database, returning empty entries ' +\n \"(add `export const dynamic = 'force-dynamic'` to app/sitemap.ts to \" +\n 'generate at runtime and avoid build-time DB access):',\n error,\n )\n return []\n }\n })\n\n /**\n * Ready-made handler for Next's `app/robots.ts`. Re-export directly:\n *\n * ```ts\n * // app/robots.ts\n * export { robots as default } from '@/lib/content'\n * ```\n */\n const robots = (): RobotsRules => buildRobots({ baseUrl: origin })\n\n return {\n getCachedPayload,\n getConfiguredLocales,\n getSettings,\n resolveRoute,\n getEntries,\n sitemap,\n robots,\n }\n}\n"],"names":["cache","getPayload","resolveRoute","resolveRouteRaw","getArchiveEntries","buildSitemapEntries","buildRobots","createContentHelpers","config","content","settingsSlug","pagesSlug","i18n","baseUrl","origin","process","env","NEXT_PUBLIC_SERVER_URL","getCachedPayload","getConfiguredLocales","c","localization","locales","map","l","code","getSettings","locale","payload","findGlobal","slug","depth","segments","page","getEntries","collection","perPage","sitemap","Error","error","console","warn","robots"],"mappings":"AAAA,SAASA,KAAK,QAAQ,QAAO;AAE7B,SAASC,UAAU,QAAQ,UAAS;AAGpC,SAASC,gBAAgBC,eAAe,EAAEC,iBAAiB,QAAQ,sBAAqB;AAGxF,SAASC,mBAAmB,EAAEC,WAAW,QAAQ,kBAAiB;AA2BlE;;;;;;;;;;;;;;;;;;;;;;;;;CAyBC,GACD,OAAO,SAASC,qBAAqB,EACnCC,MAAM,EACNC,OAAO,EACPC,eAAe,eAAe,EAC9BC,YAAY,OAAO,EACnBC,IAAI,EACJC,OAAO,EACkB;IACzB,MAAMC,SAASD,WAAWE,QAAQC,GAAG,CAACC,sBAAsB,IAAI;IAChE,MAAMC,mBAAmBlB,MAAM,UAC7BC,WAAW;YAAEO,QAAQ,MAAMA;QAAO;IAGpC,MAAMW,uBAAuBnB,MAAM;QACjC,MAAMoB,IAAI,MAAMZ;QAChB,OAAOY,EAAEC,YAAY,GAAGD,EAAEC,YAAY,CAACC,OAAO,CAACC,GAAG,CAAC,CAACC,IAAMA,EAAEC,IAAI,IAAI,EAAE;IACxE;IAEA,MAAMC,cAAc1B,MAAM,OAAO2B;QAC/B,MAAMC,UAAU,MAAMV;QACtB,OAAOU,QAAQC,UAAU,CAAC;YAAEC,MAAMpB;YAAuBiB,QAAQA;YAAiBI,OAAO;QAAE;IAC7F;IAEA,iEAAiE,GACjE,MAAM7B,eAAeF,MACnB,OACE2B,QACAK,UACAC;QAEA,MAAML,UAAU,MAAMV;QACtB,OAAOf,gBAAgB;YAAEyB;YAASD;YAAQK;YAAUC;YAAMxB;YAASE;YAAWD;QAAa;IAC7F;IAGF,gEAAgE,GAChE,MAAMwB,aAAalC,MACjB,OACEmC,YACAR,QACAM,MACAG;QAEA,MAAMR,UAAU,MAAMV;QACtB,OAAOd,kBAAkB;YAAEwB;YAASO;YAAYR;YAAQM;YAAMG;QAAQ;IACxE;IAGF;;;;;;;;;;;;;;;;;;;;GAoBC,GACD,MAAMC,UAAUrC,MAAM;QACpB,IAAI,CAACY,MAAM;YACT,MAAM,IAAI0B,MAAM;QAClB;QACA,IAAI;YACF,OAAO,MAAMjC,oBAAoB;gBAC/BuB,SAAS,MAAMV;gBACfV,QAAQI;gBACRC,SAASC;gBACTL;gBACAE;gBACAD;YACF;QACF,EAAE,OAAO6B,OAAO;YACd,2EAA2E;YAC3E,2EAA2E;YAC3E,8EAA8E;YAC9EC,QAAQC,IAAI,CACV,2EACE,wEACA,wDACFF;YAEF,OAAO,EAAE;QACX;IACF;IAEA;;;;;;;GAOC,GACD,MAAMG,SAAS,IAAmBpC,YAAY;YAAEO,SAASC;QAAO;IAEhE,OAAO;QACLI;QACAC;QACAO;QACAxB;QACAgC;QACAG;QACAK;IACF;AACF"} \ No newline at end of file diff --git a/dist/modules/seo/buildFaqJsonLd.d.ts b/dist/modules/seo/buildFaqJsonLd.d.ts new file mode 100644 index 0000000..8dca154 --- /dev/null +++ b/dist/modules/seo/buildFaqJsonLd.d.ts @@ -0,0 +1,33 @@ +type FaqItem = { + answer: string; + question: string; +}; +/** + * Builds FAQPage JSON-LD (schema.org) from Q&A pairs. Google can show these as + * expandable FAQ rich results under the page, taking more SERP space and helping + * with voice/AI answers. Strong for service landing pages. + * + * Feed it the SAME questions/answers rendered on the page (from an FAQ block in + * the panel) — the structured data must match visible content, or Google may + * flag it. Never invent Q&A that isn't on the page. + * + * import { buildFaqJsonLd } from '@intecion/ipal-kit' + * const jsonLd = buildFaqJsonLd( + * faqBlock.items.map(i => ({ question: i.question, answer: i.answer })) + * ) + * + * Returns null for empty list. + */ +export declare function buildFaqJsonLd(items: FaqItem[]): { + '@context': string; + '@type': string; + mainEntity: { + name: string; + '@type': string; + acceptedAnswer: { + '@type': string; + text: string; + }; + }[]; +} | null; +export {}; diff --git a/dist/modules/seo/buildFaqJsonLd.js b/dist/modules/seo/buildFaqJsonLd.js new file mode 100644 index 0000000..07a76ec --- /dev/null +++ b/dist/modules/seo/buildFaqJsonLd.js @@ -0,0 +1,34 @@ +/** + * Builds FAQPage JSON-LD (schema.org) from Q&A pairs. Google can show these as + * expandable FAQ rich results under the page, taking more SERP space and helping + * with voice/AI answers. Strong for service landing pages. + * + * Feed it the SAME questions/answers rendered on the page (from an FAQ block in + * the panel) — the structured data must match visible content, or Google may + * flag it. Never invent Q&A that isn't on the page. + * + * import { buildFaqJsonLd } from '@intecion/ipal-kit' + * const jsonLd = buildFaqJsonLd( + * faqBlock.items.map(i => ({ question: i.question, answer: i.answer })) + * ) + * + * Returns null for empty list. + */ export function buildFaqJsonLd(items) { + if (!items || items.length === 0) { + return null; + } + return { + '@context': 'https://schema.org', + '@type': 'FAQPage', + mainEntity: items.map((item)=>({ + name: item.question, + '@type': 'Question', + acceptedAnswer: { + '@type': 'Answer', + text: item.answer + } + })) + }; +} + +//# sourceMappingURL=buildFaqJsonLd.js.map \ No newline at end of file diff --git a/dist/modules/seo/buildFaqJsonLd.js.map b/dist/modules/seo/buildFaqJsonLd.js.map new file mode 100644 index 0000000..83d3c0b --- /dev/null +++ b/dist/modules/seo/buildFaqJsonLd.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/seo/buildFaqJsonLd.ts"],"sourcesContent":["type FaqItem = {\n answer: string\n question: string\n}\n\n/**\n * Builds FAQPage JSON-LD (schema.org) from Q&A pairs. Google can show these as\n * expandable FAQ rich results under the page, taking more SERP space and helping\n * with voice/AI answers. Strong for service landing pages.\n *\n * Feed it the SAME questions/answers rendered on the page (from an FAQ block in\n * the panel) — the structured data must match visible content, or Google may\n * flag it. Never invent Q&A that isn't on the page.\n *\n * import { buildFaqJsonLd } from '@intecion/ipal-kit'\n * const jsonLd = buildFaqJsonLd(\n * faqBlock.items.map(i => ({ question: i.question, answer: i.answer }))\n * )\n *\n * Returns null for empty list.\n */\nexport function buildFaqJsonLd(items: FaqItem[]) {\n if (!items || items.length === 0) {return null}\n\n return {\n '@context': 'https://schema.org',\n '@type': 'FAQPage',\n mainEntity: items.map((item) => ({\n name: item.question,\n '@type': 'Question',\n acceptedAnswer: {\n '@type': 'Answer',\n text: item.answer,\n },\n })),\n }\n}\n"],"names":["buildFaqJsonLd","items","length","mainEntity","map","item","name","question","acceptedAnswer","text","answer"],"mappings":"AAKA;;;;;;;;;;;;;;;CAeC,GACD,OAAO,SAASA,eAAeC,KAAgB;IAC7C,IAAI,CAACA,SAASA,MAAMC,MAAM,KAAK,GAAG;QAAC,OAAO;IAAI;IAE9C,OAAO;QACL,YAAY;QACZ,SAAS;QACTC,YAAYF,MAAMG,GAAG,CAAC,CAACC,OAAU,CAAA;gBAC/BC,MAAMD,KAAKE,QAAQ;gBACnB,SAAS;gBACTC,gBAAgB;oBACd,SAAS;oBACTC,MAAMJ,KAAKK,MAAM;gBACnB;YACF,CAAA;IACF;AACF"} \ No newline at end of file diff --git a/dist/modules/seo/buildLocalBusinessJsonLd.d.ts b/dist/modules/seo/buildLocalBusinessJsonLd.d.ts new file mode 100644 index 0000000..ef6b679 --- /dev/null +++ b/dist/modules/seo/buildLocalBusinessJsonLd.d.ts @@ -0,0 +1,71 @@ +type MediaLike = { + url?: null | string; +} | null | undefined; +type Address = { + city?: string; + country?: string; + postalCode?: string; + region?: string; + street?: string; +}; +type LocalBusinessJsonLdArgs = { + address?: Address; + /** Geo coordinates for maps/local search. */ + geo?: { + latitude: number; + longitude: number; + }; + image?: MediaLike; + logo?: MediaLike; + name: string; + /** Opening hours, e.g. ['Mo-Fr 08:00-18:00', 'Sa 09:00-14:00']. */ + openingHours?: string[]; + priceRange?: string; + sameAs?: string[]; + /** Business phone, e.g. '+48 123 456 789'. */ + telephone?: string; + url: string; +}; +/** + * Builds LocalBusiness JSON-LD (schema.org) — the key structured data for LOCAL + * SEO. Helps Google show the business in local results / map pack with address, + * hours, phone. Strong signal for "usługa + miasto" queries. + * + * All data from the panel (company global) — nothing hardcoded. Emit once in the + * root layout (business is site-wide): + * + * import { buildLocalBusinessJsonLd } from '@intecion/ipal-kit' + * const jsonLd = buildLocalBusinessJsonLd({ + * name: company.name, url: baseUrl, telephone: company.phone, + * address: company.address, openingHours: company.hours, + * }) + * + * For a more specific type (e.g. 'Dentist', 'Plumber'), override @type in the + * returned object — schema.org has many LocalBusiness subtypes. + */ +export declare function buildLocalBusinessJsonLd({ name, address, geo, image, logo, openingHours, priceRange, sameAs, telephone, url, }: LocalBusinessJsonLdArgs): { + sameAs?: string[] | undefined; + priceRange?: string | undefined; + openingHours?: string[] | undefined; + geo?: { + '@type': string; + latitude: number; + longitude: number; + } | undefined; + address?: { + addressCountry?: string | undefined; + addressRegion?: string | undefined; + postalCode?: string | undefined; + addressLocality?: string | undefined; + streetAddress?: string | undefined; + '@type': string; + } | undefined; + logo?: string | undefined; + image?: string | undefined; + telephone?: string | undefined; + name: string; + '@context': string; + '@type': string; + url: string; +}; +export {}; diff --git a/dist/modules/seo/buildLocalBusinessJsonLd.js b/dist/modules/seo/buildLocalBusinessJsonLd.js new file mode 100644 index 0000000..c45a21f --- /dev/null +++ b/dist/modules/seo/buildLocalBusinessJsonLd.js @@ -0,0 +1,73 @@ +/** + * Builds LocalBusiness JSON-LD (schema.org) — the key structured data for LOCAL + * SEO. Helps Google show the business in local results / map pack with address, + * hours, phone. Strong signal for "usługa + miasto" queries. + * + * All data from the panel (company global) — nothing hardcoded. Emit once in the + * root layout (business is site-wide): + * + * import { buildLocalBusinessJsonLd } from '@intecion/ipal-kit' + * const jsonLd = buildLocalBusinessJsonLd({ + * name: company.name, url: baseUrl, telephone: company.phone, + * address: company.address, openingHours: company.hours, + * }) + * + * For a more specific type (e.g. 'Dentist', 'Plumber'), override @type in the + * returned object — schema.org has many LocalBusiness subtypes. + */ export function buildLocalBusinessJsonLd({ name, address, geo, image, logo, openingHours, priceRange, sameAs, telephone, url }) { + const logoUrl = logo?.url; + const imageUrl = image?.url ?? logoUrl; + return { + name, + '@context': 'https://schema.org', + '@type': 'LocalBusiness', + url, + ...telephone ? { + telephone + } : {}, + ...imageUrl ? { + image: imageUrl.startsWith('http') ? imageUrl : `${url}${imageUrl}` + } : {}, + ...logoUrl ? { + logo: logoUrl.startsWith('http') ? logoUrl : `${url}${logoUrl}` + } : {}, + ...address ? { + address: { + '@type': 'PostalAddress', + ...address.street ? { + streetAddress: address.street + } : {}, + ...address.city ? { + addressLocality: address.city + } : {}, + ...address.postalCode ? { + postalCode: address.postalCode + } : {}, + ...address.region ? { + addressRegion: address.region + } : {}, + ...address.country ? { + addressCountry: address.country + } : {} + } + } : {}, + ...geo ? { + geo: { + '@type': 'GeoCoordinates', + latitude: geo.latitude, + longitude: geo.longitude + } + } : {}, + ...openingHours && openingHours.length > 0 ? { + openingHours + } : {}, + ...priceRange ? { + priceRange + } : {}, + ...sameAs && sameAs.length > 0 ? { + sameAs + } : {} + }; +} + +//# sourceMappingURL=buildLocalBusinessJsonLd.js.map \ No newline at end of file diff --git a/dist/modules/seo/buildLocalBusinessJsonLd.js.map b/dist/modules/seo/buildLocalBusinessJsonLd.js.map new file mode 100644 index 0000000..675cdde --- /dev/null +++ b/dist/modules/seo/buildLocalBusinessJsonLd.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/seo/buildLocalBusinessJsonLd.ts"],"sourcesContent":["type MediaLike = { url?: null | string } | null | undefined\n\ntype Address = {\n city?: string\n country?: string // ISO code, e.g. 'PL'\n postalCode?: string\n region?: string\n street?: string\n}\n\ntype LocalBusinessJsonLdArgs = {\n address?: Address\n /** Geo coordinates for maps/local search. */\n geo?: { latitude: number; longitude: number }\n image?: MediaLike\n logo?: MediaLike\n name: string\n /** Opening hours, e.g. ['Mo-Fr 08:00-18:00', 'Sa 09:00-14:00']. */\n openingHours?: string[]\n priceRange?: string // e.g. '$$'\n sameAs?: string[]\n /** Business phone, e.g. '+48 123 456 789'. */\n telephone?: string\n url: string\n}\n\n/**\n * Builds LocalBusiness JSON-LD (schema.org) — the key structured data for LOCAL\n * SEO. Helps Google show the business in local results / map pack with address,\n * hours, phone. Strong signal for \"usługa + miasto\" queries.\n *\n * All data from the panel (company global) — nothing hardcoded. Emit once in the\n * root layout (business is site-wide):\n *\n * import { buildLocalBusinessJsonLd } from '@intecion/ipal-kit'\n * const jsonLd = buildLocalBusinessJsonLd({\n * name: company.name, url: baseUrl, telephone: company.phone,\n * address: company.address, openingHours: company.hours,\n * })\n *\n * For a more specific type (e.g. 'Dentist', 'Plumber'), override @type in the\n * returned object — schema.org has many LocalBusiness subtypes.\n */\nexport function buildLocalBusinessJsonLd({\n name,\n address,\n geo,\n image,\n logo,\n openingHours,\n priceRange,\n sameAs,\n telephone,\n url,\n}: LocalBusinessJsonLdArgs) {\n const logoUrl = logo?.url\n const imageUrl = image?.url ?? logoUrl\n\n return {\n name,\n '@context': 'https://schema.org',\n '@type': 'LocalBusiness',\n url,\n ...(telephone ? { telephone } : {}),\n ...(imageUrl ? { image: imageUrl.startsWith('http') ? imageUrl : `${url}${imageUrl}` } : {}),\n ...(logoUrl ? { logo: logoUrl.startsWith('http') ? logoUrl : `${url}${logoUrl}` } : {}),\n ...(address\n ? {\n address: {\n '@type': 'PostalAddress',\n ...(address.street ? { streetAddress: address.street } : {}),\n ...(address.city ? { addressLocality: address.city } : {}),\n ...(address.postalCode ? { postalCode: address.postalCode } : {}),\n ...(address.region ? { addressRegion: address.region } : {}),\n ...(address.country ? { addressCountry: address.country } : {}),\n },\n }\n : {}),\n ...(geo\n ? { geo: { '@type': 'GeoCoordinates', latitude: geo.latitude, longitude: geo.longitude } }\n : {}),\n ...(openingHours && openingHours.length > 0 ? { openingHours } : {}),\n ...(priceRange ? { priceRange } : {}),\n ...(sameAs && sameAs.length > 0 ? { sameAs } : {}),\n }\n}\n"],"names":["buildLocalBusinessJsonLd","name","address","geo","image","logo","openingHours","priceRange","sameAs","telephone","url","logoUrl","imageUrl","startsWith","street","streetAddress","city","addressLocality","postalCode","region","addressRegion","country","addressCountry","latitude","longitude","length"],"mappings":"AA0BA;;;;;;;;;;;;;;;;CAgBC,GACD,OAAO,SAASA,yBAAyB,EACvCC,IAAI,EACJC,OAAO,EACPC,GAAG,EACHC,KAAK,EACLC,IAAI,EACJC,YAAY,EACZC,UAAU,EACVC,MAAM,EACNC,SAAS,EACTC,GAAG,EACqB;IACxB,MAAMC,UAAUN,MAAMK;IACtB,MAAME,WAAWR,OAAOM,OAAOC;IAE/B,OAAO;QACLV;QACA,YAAY;QACZ,SAAS;QACTS;QACA,GAAID,YAAY;YAAEA;QAAU,IAAI,CAAC,CAAC;QAClC,GAAIG,WAAW;YAAER,OAAOQ,SAASC,UAAU,CAAC,UAAUD,WAAW,GAAGF,MAAME,UAAU;QAAC,IAAI,CAAC,CAAC;QAC3F,GAAID,UAAU;YAAEN,MAAMM,QAAQE,UAAU,CAAC,UAAUF,UAAU,GAAGD,MAAMC,SAAS;QAAC,IAAI,CAAC,CAAC;QACtF,GAAIT,UACA;YACEA,SAAS;gBACP,SAAS;gBACT,GAAIA,QAAQY,MAAM,GAAG;oBAAEC,eAAeb,QAAQY,MAAM;gBAAC,IAAI,CAAC,CAAC;gBAC3D,GAAIZ,QAAQc,IAAI,GAAG;oBAAEC,iBAAiBf,QAAQc,IAAI;gBAAC,IAAI,CAAC,CAAC;gBACzD,GAAId,QAAQgB,UAAU,GAAG;oBAAEA,YAAYhB,QAAQgB,UAAU;gBAAC,IAAI,CAAC,CAAC;gBAChE,GAAIhB,QAAQiB,MAAM,GAAG;oBAAEC,eAAelB,QAAQiB,MAAM;gBAAC,IAAI,CAAC,CAAC;gBAC3D,GAAIjB,QAAQmB,OAAO,GAAG;oBAAEC,gBAAgBpB,QAAQmB,OAAO;gBAAC,IAAI,CAAC,CAAC;YAChE;QACF,IACA,CAAC,CAAC;QACN,GAAIlB,MACA;YAAEA,KAAK;gBAAE,SAAS;gBAAkBoB,UAAUpB,IAAIoB,QAAQ;gBAAEC,WAAWrB,IAAIqB,SAAS;YAAC;QAAE,IACvF,CAAC,CAAC;QACN,GAAIlB,gBAAgBA,aAAamB,MAAM,GAAG,IAAI;YAAEnB;QAAa,IAAI,CAAC,CAAC;QACnE,GAAIC,aAAa;YAAEA;QAAW,IAAI,CAAC,CAAC;QACpC,GAAIC,UAAUA,OAAOiB,MAAM,GAAG,IAAI;YAAEjB;QAAO,IAAI,CAAC,CAAC;IACnD;AACF"} \ No newline at end of file diff --git a/dist/modules/seo/buildMetadata.d.ts b/dist/modules/seo/buildMetadata.d.ts index 9d30e57..17da050 100644 --- a/dist/modules/seo/buildMetadata.d.ts +++ b/dist/modules/seo/buildMetadata.d.ts @@ -19,6 +19,10 @@ export type PageMetadata = { locale?: string; title: string; }; + robots?: { + follow: boolean; + index: boolean; + }; title: string; }; type BuildMetadataArgs = { diff --git a/dist/modules/seo/buildMetadata.js b/dist/modules/seo/buildMetadata.js index 1a08dc3..8635813 100644 --- a/dist/modules/seo/buildMetadata.js +++ b/dist/modules/seo/buildMetadata.js @@ -60,6 +60,12 @@ import { buildHreflangAlternates } from './hreflang.js'; languages } }, + ...meta?.noindex ? { + robots: { + follow: true, + index: false + } + } : {}, openGraph: { title, ...description && { diff --git a/dist/modules/seo/buildMetadata.js.map b/dist/modules/seo/buildMetadata.js.map index 5b52d9a..8674ebd 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 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?: 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 * 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 /** 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 prefix,\n query,\n separator,\n siteName,\n slugs,\n}: BuildMetadataArgs): PageMetadata {\n // titleOverride wins outright: an editor who filled it in wants that exact\n // string in the tab, not a composition.\n const override = meta?.titleOverride?.trim()\n const title = override || composeTitle({ order, pageTitle: meta?.title, separator, siteName })\n const description = meta?.description?.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 }\n}\n"],"names":["buildLocalizedPath","composeTitle","buildHreflangAlternates","buildMetadata","baseUrl","config","homeSlug","imageUrl","locale","meta","order","prefix","query","separator","siteName","slugs","override","titleOverride","trim","title","pageTitle","description","undefined","origin","replace","suffix","currentPath","canonical","languages","code","Object","keys","images","url","alternates","length","openGraph"],"mappings":"AAIA,SAASA,kBAAkB,QAAQ,mBAAkB;AACrD,SAASC,YAAY,QAAQ,oBAAmB;AAChD,SAASC,uBAAuB,QAAQ,gBAAe;AAyDvD;;;;;;;;CAQC,GACD,OAAO,SAASC,cAAc,EAC5BC,OAAO,EACPC,MAAM,EACNC,WAAW,MAAM,EACjBC,QAAQ,EACRC,MAAM,EACNC,IAAI,EACJC,KAAK,EACLC,MAAM,EACNC,KAAK,EACLC,SAAS,EACTC,QAAQ,EACRC,KAAK,EACa;IAClB,2EAA2E;IAC3E,wCAAwC;IACxC,MAAMC,WAAWP,MAAMQ,eAAeC;IACtC,MAAMC,QAAQH,YAAYf,aAAa;QAAES;QAAOU,WAAWX,MAAMU;QAAON;QAAWC;IAAS;IAC5F,MAAMO,cAAcZ,MAAMY,aAAaH,UAAUI;IACjD,MAAMC,SAASnB,SAASoB,QAAQ,OAAO,OAAO;IAE9C,MAAMC,SAASb,SAAS;IAExB,MAAMc,cAAc1B,mBAAmB;QAAEK;QAAQC;QAAUE;QAAQG;QAAQI;IAAM;IACjF,MAAMY,YAAYD,cAAc,GAAGH,SAASG,cAAcD,QAAQ,GAAGH;IAErE,MAAMM,YAAY1B,wBAAwB;QAAEE;QAASC;QAAQC;QAAUK;QAAQI;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,SAASzB,WAAW;QAAC;YAAE0B,KAAK1B;QAAS;KAAE,GAAGe;IAEhD,OAAO;QACLH;QACA,GAAIE,eAAe;YAAEA;QAAY,CAAC;QAClCa,YAAY;YACV,GAAIP,aAAa;gBAAEA;YAAU,CAAC;YAC9B,GAAIG,OAAOC,IAAI,CAACH,WAAWO,MAAM,GAAG,KAAK;gBAAEP;YAAU,CAAC;QACxD;QACAQ,WAAW;YACTjB;YACA,GAAIE,eAAe;gBAAEA;YAAY,CAAC;YAClC,GAAIW,UAAU;gBAAEA;YAAO,CAAC;YACxBxB;QACF;IACF;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 title: string\n }\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?: 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 * 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 /** 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 prefix,\n query,\n separator,\n siteName,\n slugs,\n}: BuildMetadataArgs): PageMetadata {\n // titleOverride wins outright: an editor who filled it in wants that exact\n // string in the tab, not a composition.\n const override = meta?.titleOverride?.trim()\n const title = override || composeTitle({ order, pageTitle: meta?.title, separator, siteName })\n const description = meta?.description?.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 ...(meta?.noindex ? { robots: { follow: true, index: false } } : {}),\n openGraph: {\n title,\n ...(description && { description }),\n ...(images && { images }),\n locale,\n },\n }\n}\n"],"names":["buildLocalizedPath","composeTitle","buildHreflangAlternates","buildMetadata","baseUrl","config","homeSlug","imageUrl","locale","meta","order","prefix","query","separator","siteName","slugs","override","titleOverride","trim","title","pageTitle","description","undefined","origin","replace","suffix","currentPath","canonical","languages","code","Object","keys","images","url","alternates","length","noindex","robots","follow","index","openGraph"],"mappings":"AAIA,SAASA,kBAAkB,QAAQ,mBAAkB;AACrD,SAASC,YAAY,QAAQ,oBAAmB;AAChD,SAASC,uBAAuB,QAAQ,gBAAe;AA6DvD;;;;;;;;CAQC,GACD,OAAO,SAASC,cAAc,EAC5BC,OAAO,EACPC,MAAM,EACNC,WAAW,MAAM,EACjBC,QAAQ,EACRC,MAAM,EACNC,IAAI,EACJC,KAAK,EACLC,MAAM,EACNC,KAAK,EACLC,SAAS,EACTC,QAAQ,EACRC,KAAK,EACa;IAClB,2EAA2E;IAC3E,wCAAwC;IACxC,MAAMC,WAAWP,MAAMQ,eAAeC;IACtC,MAAMC,QAAQH,YAAYf,aAAa;QAAES;QAAOU,WAAWX,MAAMU;QAAON;QAAWC;IAAS;IAC5F,MAAMO,cAAcZ,MAAMY,aAAaH,UAAUI;IACjD,MAAMC,SAASnB,SAASoB,QAAQ,OAAO,OAAO;IAE9C,MAAMC,SAASb,SAAS;IAExB,MAAMc,cAAc1B,mBAAmB;QAAEK;QAAQC;QAAUE;QAAQG;QAAQI;IAAM;IACjF,MAAMY,YAAYD,cAAc,GAAGH,SAASG,cAAcD,QAAQ,GAAGH;IAErE,MAAMM,YAAY1B,wBAAwB;QAAEE;QAASC;QAAQC;QAAUK;QAAQI;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,SAASzB,WAAW;QAAC;YAAE0B,KAAK1B;QAAS;KAAE,GAAGe;IAEhD,OAAO;QACLH;QACA,GAAIE,eAAe;YAAEA;QAAY,CAAC;QAClCa,YAAY;YACV,GAAIP,aAAa;gBAAEA;YAAU,CAAC;YAC9B,GAAIG,OAAOC,IAAI,CAACH,WAAWO,MAAM,GAAG,KAAK;gBAAEP;YAAU,CAAC;QACxD;QACA,GAAInB,MAAM2B,UAAU;YAAEC,QAAQ;gBAAEC,QAAQ;gBAAMC,OAAO;YAAM;QAAE,IAAI,CAAC,CAAC;QACnEC,WAAW;YACTrB;YACA,GAAIE,eAAe;gBAAEA;YAAY,CAAC;YAClC,GAAIW,UAAU;gBAAEA;YAAO,CAAC;YACxBxB;QACF;IACF;AACF"} \ No newline at end of file diff --git a/dist/modules/seo/buildServiceJsonLd.d.ts b/dist/modules/seo/buildServiceJsonLd.d.ts new file mode 100644 index 0000000..8bb7009 --- /dev/null +++ b/dist/modules/seo/buildServiceJsonLd.d.ts @@ -0,0 +1,39 @@ +type ServiceJsonLdArgs = { + /** Area served, e.g. 'Wrocław' or ['Wrocław', 'Oława']. */ + areaServed?: string | string[]; + description?: string; + /** Service name, e.g. 'Sprzątanie biur'. */ + name: string; + /** Provider (business) name. */ + providerName: string; + /** Service type / category. */ + serviceType?: string; + url: string; +}; +/** + * Builds Service JSON-LD (schema.org) for a service offering. Helps Google + * understand "what this page sells" — useful for service landing pages + * ("usługa + miasto"). Pairs well with LocalBusiness (the provider). + * + * Per-page (each service page emits its own), data from the panel: + * + * import { buildServiceJsonLd } from '@intecion/ipal-kit' + * const jsonLd = buildServiceJsonLd({ + * name: page.serviceName, providerName: company.name, + * url: pageUrl, areaServed: 'Wrocław', + * }) + */ +export declare function buildServiceJsonLd({ name, areaServed, description, providerName, serviceType, url, }: ServiceJsonLdArgs): { + serviceType?: string | undefined; + areaServed?: string | string[] | undefined; + description?: string | undefined; + name: string; + '@context': string; + '@type': string; + provider: { + name: string; + '@type': string; + url: string; + }; +}; +export {}; diff --git a/dist/modules/seo/buildServiceJsonLd.js b/dist/modules/seo/buildServiceJsonLd.js new file mode 100644 index 0000000..1c639d0 --- /dev/null +++ b/dist/modules/seo/buildServiceJsonLd.js @@ -0,0 +1,35 @@ +/** + * Builds Service JSON-LD (schema.org) for a service offering. Helps Google + * understand "what this page sells" — useful for service landing pages + * ("usługa + miasto"). Pairs well with LocalBusiness (the provider). + * + * Per-page (each service page emits its own), data from the panel: + * + * import { buildServiceJsonLd } from '@intecion/ipal-kit' + * const jsonLd = buildServiceJsonLd({ + * name: page.serviceName, providerName: company.name, + * url: pageUrl, areaServed: 'Wrocław', + * }) + */ export function buildServiceJsonLd({ name, areaServed, description, providerName, serviceType, url }) { + return { + name, + '@context': 'https://schema.org', + '@type': 'Service', + provider: { + name: providerName, + '@type': 'LocalBusiness', + url + }, + ...description ? { + description + } : {}, + ...areaServed ? { + areaServed + } : {}, + ...serviceType ? { + serviceType + } : {} + }; +} + +//# sourceMappingURL=buildServiceJsonLd.js.map \ No newline at end of file diff --git a/dist/modules/seo/buildServiceJsonLd.js.map b/dist/modules/seo/buildServiceJsonLd.js.map new file mode 100644 index 0000000..3fb9754 --- /dev/null +++ b/dist/modules/seo/buildServiceJsonLd.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/seo/buildServiceJsonLd.ts"],"sourcesContent":["type ServiceJsonLdArgs = {\n /** Area served, e.g. 'Wrocław' or ['Wrocław', 'Oława']. */\n areaServed?: string | string[]\n description?: string\n /** Service name, e.g. 'Sprzątanie biur'. */\n name: string\n /** Provider (business) name. */\n providerName: string\n /** Service type / category. */\n serviceType?: string\n url: string\n}\n\n/**\n * Builds Service JSON-LD (schema.org) for a service offering. Helps Google\n * understand \"what this page sells\" — useful for service landing pages\n * (\"usługa + miasto\"). Pairs well with LocalBusiness (the provider).\n *\n * Per-page (each service page emits its own), data from the panel:\n *\n * import { buildServiceJsonLd } from '@intecion/ipal-kit'\n * const jsonLd = buildServiceJsonLd({\n * name: page.serviceName, providerName: company.name,\n * url: pageUrl, areaServed: 'Wrocław',\n * })\n */\nexport function buildServiceJsonLd({\n name,\n areaServed,\n description,\n providerName,\n serviceType,\n url,\n}: ServiceJsonLdArgs) {\n return {\n name,\n '@context': 'https://schema.org',\n '@type': 'Service',\n provider: {\n name: providerName,\n '@type': 'LocalBusiness',\n url,\n },\n ...(description ? { description } : {}),\n ...(areaServed ? { areaServed } : {}),\n ...(serviceType ? { serviceType } : {}),\n }\n}\n"],"names":["buildServiceJsonLd","name","areaServed","description","providerName","serviceType","url","provider"],"mappings":"AAaA;;;;;;;;;;;;CAYC,GACD,OAAO,SAASA,mBAAmB,EACjCC,IAAI,EACJC,UAAU,EACVC,WAAW,EACXC,YAAY,EACZC,WAAW,EACXC,GAAG,EACe;IAClB,OAAO;QACLL;QACA,YAAY;QACZ,SAAS;QACTM,UAAU;YACRN,MAAMG;YACN,SAAS;YACTE;QACF;QACA,GAAIH,cAAc;YAAEA;QAAY,IAAI,CAAC,CAAC;QACtC,GAAID,aAAa;YAAEA;QAAW,IAAI,CAAC,CAAC;QACpC,GAAIG,cAAc;YAAEA;QAAY,IAAI,CAAC,CAAC;IACxC;AACF"} \ No newline at end of file diff --git a/dist/modules/seo/index.d.ts b/dist/modules/seo/index.d.ts index 5072e81..adabd71 100644 --- a/dist/modules/seo/index.d.ts +++ b/dist/modules/seo/index.d.ts @@ -1,12 +1,15 @@ export { buildAutoFillMetaHook } from './autoFillMeta.js'; export type { AutoFillMapping } from './autoFillMeta.js'; export { buildBreadcrumbJsonLd } from './buildBreadcrumbJsonLd.js'; +export { buildFaqJsonLd } from './buildFaqJsonLd.js'; export { buildIconsMetadata } from './buildIconsMetadata.js'; +export { buildLocalBusinessJsonLd } from './buildLocalBusinessJsonLd.js'; export { buildMetadata } from './buildMetadata.js'; export type { PageMetadata } from './buildMetadata.js'; export { buildOrganizationJsonLd } from './buildOrganizationJsonLd.js'; export { buildRobots } from './buildRobots.js'; export type { RobotsRules } from './buildRobots.js'; +export { buildServiceJsonLd } from './buildServiceJsonLd.js'; export { buildSitemapEntries } from './buildSitemapEntries.js'; export type { SitemapEntry } from './buildSitemapEntries.js'; export { buildSiteNavigationJsonLd } from './buildSiteNavigationJsonLd.js'; diff --git a/dist/modules/seo/index.js b/dist/modules/seo/index.js index 9b1e53d..f7b25d2 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 { buildFaqJsonLd } from './buildFaqJsonLd.js'; export { buildIconsMetadata } from './buildIconsMetadata.js'; +export { buildLocalBusinessJsonLd } from './buildLocalBusinessJsonLd.js'; export { buildMetadata } from './buildMetadata.js'; export { buildOrganizationJsonLd } from './buildOrganizationJsonLd.js'; export { buildRobots } from './buildRobots.js'; +export { buildServiceJsonLd } from './buildServiceJsonLd.js'; export { buildSitemapEntries } from './buildSitemapEntries.js'; export { buildSiteNavigationJsonLd } from './buildSiteNavigationJsonLd.js'; export { buildWebSiteJsonLd } from './buildWebSiteJsonLd.js'; diff --git a/dist/modules/seo/index.js.map b/dist/modules/seo/index.js.map index 4a74dd8..5211e11 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 { 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 +{"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 { buildFaqJsonLd } from './buildFaqJsonLd.js'\nexport { buildIconsMetadata } from './buildIconsMetadata.js'\nexport { buildLocalBusinessJsonLd } from './buildLocalBusinessJsonLd.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 { buildServiceJsonLd } from './buildServiceJsonLd.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","buildFaqJsonLd","buildIconsMetadata","buildLocalBusinessJsonLd","buildMetadata","buildOrganizationJsonLd","buildRobots","buildServiceJsonLd","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,cAAc,QAAQ,sBAAqB;AACpD,SAASC,kBAAkB,QAAQ,0BAAyB;AAC5D,SAASC,wBAAwB,QAAQ,gCAA+B;AACxE,SAASC,aAAa,QAAQ,qBAAoB;AAElD,SAASC,uBAAuB,QAAQ,+BAA8B;AACtE,SAASC,WAAW,QAAQ,mBAAkB;AAE9C,SAASC,kBAAkB,QAAQ,0BAAyB;AAC5D,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/dist/modules/seo/types.d.ts b/dist/modules/seo/types.d.ts index 1965441..5746c1f 100644 --- a/dist/modules/seo/types.d.ts +++ b/dist/modules/seo/types.d.ts @@ -34,6 +34,7 @@ export type SeoOption = { export type SeoMeta = { description?: null | string; image?: unknown; + noindex?: boolean | null; title?: null | string; /** When set, used as the whole title — no site name, no separator. */ titleOverride?: null | string; diff --git a/dist/modules/seo/types.js.map b/dist/modules/seo/types.js.map index 6d93404..339039f 100644 --- a/dist/modules/seo/types.js.map +++ b/dist/modules/seo/types.js.map @@ -1 +1 @@ -{"version":3,"sources":["../../../src/modules/seo/types.ts"],"sourcesContent":["import type { Field } from 'payload'\n\nimport type { AutoFillMapping } from './autoFillMeta.js'\n\n/**\n * SEO configuration.\n *\n * The plugin wires @payloadcms/plugin-seo into the client's config and adds\n * locale-aware metadata helpers on top. Collections come from options because\n * the plugin doesn't own the client's content collections (e.g. Pages).\n */\nexport type SeoOption = {\n /**\n * Auto-fill empty meta from document fields on save. Defaults to the website\n * template mapping (title → meta.title). Set to false to disable.\n */\n autoFill?: AutoFillMapping | false\n /** Collection slugs that receive SEO meta fields, e.g. ['pages', 'posts']. */\n collections: string[]\n /** Extra fields appended to the SEO group in the admin. */\n fields?: Field[]\n /** Optional: customize how meta descriptions are generated. */\n generateDescription?: (args: { doc: Record }) => string\n /** Optional: customize how meta titles are generated in the admin preview. */\n generateTitle?: (args: { doc: Record }) => string\n}\n\n/**\n * Minimal shape of the SEO meta group as stored on a document by\n * @payloadcms/plugin-seo. The client's generated types are richer; helpers\n * depend only on this.\n */\nexport type SeoMeta = {\n description?: null | string\n image?: unknown\n title?: null | string\n /** When set, used as the whole title — no site name, no separator. */\n titleOverride?: null | string\n}\n"],"names":[],"mappings":"AA2BA;;;;CAIC,GACD,WAMC"} \ No newline at end of file +{"version":3,"sources":["../../../src/modules/seo/types.ts"],"sourcesContent":["import type { Field } from 'payload'\n\nimport type { AutoFillMapping } from './autoFillMeta.js'\n\n/**\n * SEO configuration.\n *\n * The plugin wires @payloadcms/plugin-seo into the client's config and adds\n * locale-aware metadata helpers on top. Collections come from options because\n * the plugin doesn't own the client's content collections (e.g. Pages).\n */\nexport type SeoOption = {\n /**\n * Auto-fill empty meta from document fields on save. Defaults to the website\n * template mapping (title → meta.title). Set to false to disable.\n */\n autoFill?: AutoFillMapping | false\n /** Collection slugs that receive SEO meta fields, e.g. ['pages', 'posts']. */\n collections: string[]\n /** Extra fields appended to the SEO group in the admin. */\n fields?: Field[]\n /** Optional: customize how meta descriptions are generated. */\n generateDescription?: (args: { doc: Record }) => string\n /** Optional: customize how meta titles are generated in the admin preview. */\n generateTitle?: (args: { doc: Record }) => string\n}\n\n/**\n * Minimal shape of the SEO meta group as stored on a document by\n * @payloadcms/plugin-seo. The client's generated types are richer; helpers\n * depend only on this.\n */\nexport type SeoMeta = {\n description?: null | string\n image?: unknown\n noindex?: boolean | null\n title?: null | string\n /** When set, used as the whole title — no site name, no separator. */\n titleOverride?: null | string\n}\n"],"names":[],"mappings":"AA2BA;;;;CAIC,GACD,WAOC"} \ No newline at end of file diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..03dde0b --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,206 @@ +# Deployment — zmienne środowiskowe i produkcja + +Jedno źródło prawdy o zmiennych środowiskowych (wszystkie, co znaczą, wymagane +czy nie) oraz jak wdrożyć projekt na produkcję spójnie. Env jest częścią +deploymentu — te same zmienne w dev (.env) i na produkcji (runtime hostingu). + +Powiązane: [getting-started.md](./getting-started.md), [storage.md](./storage.md) +(R2), [email.md](./email.md) (Graph), [security.md](./security.md). + +--- + +## 1. ZMIENNE ŚRODOWISKOWE — pełna lista + +### Rdzeń (WYMAGANE — projekt bez nich nie wstanie) + +```bash +# Baza danych (Mongo albo Postgres — zależnie od projektu) +DATABASE_URI=mongodb://... # albo postgres://... / file:./dev.db (dev) + +# Sekret Payload (podpisywanie sesji/tokenów) — losowy, długi +PAYLOAD_SECRET= + +# Publiczny URL serwisu (canonical, hreflang, OG, manifest) +NEXT_PUBLIC_SERVER_URL=https://klient.pl # dev: http://localhost:3000 +``` + +### Email — Graph (OPCJONALNE, agencyjne, gdy transport = Graph) + +```bash +GRAPH_TENANT_ID= +GRAPH_CLIENT_ID= +GRAPH_CLIENT_SECRET= +GRAPH_SENDER=noreply@mailservice.intecion.net # wspólna skrzynka +``` +Bez nich transport Graph nie zadziała (fallback SMTP). Patrz email.md. + +### Storage — R2 (OPCJONALNE, gdy media w R2) + +```bash +R2_BUCKET= +R2_ENDPOINT=https://.r2.cloudflarestorage.com +R2_ACCESS_KEY_ID= +R2_SECRET_ACCESS_KEY= +R2_PUBLIC_URL=https://media.klient.pl # custom domena (obrazy publiczne) +``` +Brak → fallback na lokalny dysk. Patrz storage.md. + +### Tabela — wszystkie zmienne + +| Zmienna | Wymagana | Warstwa | Opis | +|---|---|---|---| +| `DATABASE_URI` | ✅ | infra | połączenie z bazą | +| `PAYLOAD_SECRET` | ✅ | infra | sekret Payload | +| `NEXT_PUBLIC_SERVER_URL` | ✅ | infra | publiczny URL (canonical, OG) | +| `GRAPH_TENANT_ID` | ⬜ | email | Azure tenant (Graph) | +| `GRAPH_CLIENT_ID` | ⬜ | email | Azure app id | +| `GRAPH_CLIENT_SECRET` | ⬜ | email | Azure secret | +| `GRAPH_SENDER` | ⬜ | email | skrzynka nadawcza | +| `R2_BUCKET` | ⬜ | storage | bucket R2 | +| `R2_ENDPOINT` | ⬜ | storage | endpoint S3 R2 | +| `R2_ACCESS_KEY_ID` | ⬜ | storage | klucz R2 | +| `R2_SECRET_ACCESS_KEY` | ⬜ | storage | sekret R2 | +| `R2_PUBLIC_URL` | ⬜ | storage | custom domena mediów | + +**Zasada:** wszystkie sekrety to zmienne agencyjne/infrastrukturalne — w `.env` +(dev) i runtime hostingu (prod), NIGDY w repo. Dane per-projekt edytowalne przez +redaktora idą do PANELU, nie do env (patrz architektura-tresci.md). + +### .env.example — zawsze w repo + +Każdy projekt ma `.env.example` z listą zmiennych (bez wartości/sekretów) — +szablon dla następnej osoby. Commituj go; `.env` (z wartościami) NIGDY. + +--- + +## 2. PRZED DEPLOYEM — checklist + +- [ ] `pnpm build --webpack` przechodzi LOKALNIE (nie tylko dev) +- [ ] Wszystkie wymagane env ustawione na hostingu (runtime) +- [ ] `NEXT_PUBLIC_SERVER_URL` = produkcyjny URL (nie localhost) +- [ ] `PAYLOAD_SECRET` inny niż w dev (produkcyjny sekret) +- [ ] Baza produkcyjna (nie dev/SQLite) +- [ ] HSTS włączony (buildSecurityHeaders hsts: production) +- [ ] Media: R2 z custom domeną (jeśli używane) — obrazy publiczne +- [ ] Migracja mediów lokalne→R2 (jeśli przełączasz) +- [ ] Strony polityk + baner cookies (patrz wymagania-prawne.md) + +--- + +## 3. BUDOWANIE NA PRODUKCJĘ + +### Build script (Next 16 + Payload) + +```json +// package.json — --webpack KONIECZNE (Turbopack konfliktuje z withPayload) +"build": "cross-env NODE_OPTIONS=\"--max-old-space-size=3072\" next build --webpack" +``` + +`--max-old-space-size` — Payload + Next bywają pamięciożerne przy buildzie; +3072 MB zapobiega OOM na mniejszych maszynach. + +### Kolejność build → migracje → start + +```bash +pnpm install --frozen-lockfile # dokładnie z lockfile (powtarzalny build) +pnpm generate:types # typy z kolekcji +pnpm build # --webpack +# migracje bazy (jeśli Postgres z migracjami): +pnpm payload migrate +pnpm start # produkcyjny serwer +``` + +--- + +## 3a. Pułapka: metadata w zamiast (htmlLimitedBots) + +Next 16 streamuje metadata dynamicznych stron do `` (przenosi do head +skryptem JS). Crawlery bez JS widzą canonical/hreflang/title/favicon poza head → +utrata SEO. **Każdy projekt** tego potrzebuje w next.config: + +```ts +const nextConfig: NextConfig = { + htmlLimitedBots: + /Googlebot|Google-InspectionTool|Bingbot|Yandex|DuckDuckBot|Screaming Frog|AhrefsBot|SemrushBot/i, + // ... +} +``` + +Weryfikacja: `curl -A "Googlebot" URL | grep canonical` — musi być w ``. +Szczegóły i objawy: seo.md (sekcja htmlLimitedBots). + +## 3b. Pułapka: prerender tras zależnych od bazy (KONIECZNE) + +Next domyślnie **prerenderuje** trasy typu `sitemap.ts` w czasie `next build` — +traktuje je jako statyczne. Jeśli taka trasa czyta bazę (sitemap → Payload → +Mongo/Postgres), build **próbuje połączyć się z bazą**. A kontener budujący +(Coolify/Docker/Railway/CI) zwykle NIE ma dostępu do sieci bazy → połączenie +pada (`ENOTFOUND`, `MongooseServerSelectionError`) → **build się wywala**. + +**Rozwiązanie — `force-dynamic` na trasach zależnych od bazy:** +```ts +// app/sitemap.ts +export { sitemap as default } from '@/lib/content' +export const dynamic = 'force-dynamic' // generuj w runtime, nie w buildzie +``` + +To mówi Next: nie prerenderuj w buildzie, generuj w runtime (gdy baza jest +dostępna). Dotyczy KAŻDEJ trasy czytającej bazę podczas renderowania: +- `app/sitemap.ts` → `force-dynamic` +- inne trasy/strony czytające bazę w prerenderze → rozważ `force-dynamic` albo + obsłuż błąd bazy (try/catch z fallbackiem) + +Plugin dodatkowo zabezpiecza handler sitemap (łapie błąd bazy, zwraca pustą +mapę), więc build nie padnie nawet bez `force-dynamic` — ale to siatka +bezpieczeństwa, nie właściwe rozwiązanie. Zawsze dodawaj `force-dynamic`. + +**Strona 404** (`not-found.tsx`) czytająca ustawienia z bazy — ten sam problem. +Opakuj `getCachedPayload()` w try/catch, żeby brak bazy w buildzie nie wywalił +prerenderu 404 (fallback na statyczne teksty). + +**Weryfikacja lokalna** (symuluj brak bazy): +```bash +DATABASE_URI=mongodb://invalid-host:27017/test pnpm build +# build musi przejść (kod 0), mimo niedostępnej bazy +``` + +## 4. HOSTING (Coolify / Docker) + +### Zmienne runtime, nie build + +Zmienne środowiskowe ustaw w **runtime** hostingu (Coolify → Environment +Variables), nie zapiekaj w build. `NEXT_PUBLIC_*` są wyjątkiem — wchodzą w build +(bo publiczne, w bundlu klienta), więc muszą być dostępne PODCZAS buildu. + +### Persystencja mediów + +Jeśli media lokalne (nie R2) — potrzebują **wolumenu** (inaczej znikną przy +redeployu). Dlatego R2 jest zalecane na produkcji: media poza kontenerem, +przetrwają redeploy. Patrz storage.md. + +### Health check + +Payload wystawia panel pod `/admin` — health check może pingować stronę główną +albo `/admin`. Nie ustawiaj health check na endpoint wymagający bazy, jeśli +baza wstaje wolniej niż app. + +--- + +## 5. PO DEPLOYU — weryfikacja + +- [ ] Strona główna `/` przekierowuje na locale (`/pl`) +- [ ] Panel `/admin` działa, logowanie OK +- [ ] Formularz wysyła (test przez panel: Send test) +- [ ] Media się wyświetlają (jeśli R2 — custom domena działa, nie 403) +- [ ] Favicon w `` (patrz seo.md — Google cache'uje wolno) +- [ ] HTTPS + nagłówki bezpieczeństwa (sprawdź np. securityheaders.com) +- [ ] Sitemap `/sitemap.xml` i `/robots.txt` odpowiadają + +--- + +## DLACZEGO TO WAŻNE + +- **Jedna lista env** — nikt nie zgaduje, czego brakuje +- **Powtarzalny deploy** — frozen-lockfile, ta sama kolejność, każdy projekt tak samo +- **Sekrety bezpieczne** — env/runtime, nigdy repo +- **Media przetrwają** — R2 albo wolumen, nie znikają przy redeployu \ No newline at end of file diff --git a/docs/i18n.md b/docs/i18n.md index f77201c..0e9ef80 100644 --- a/docs/i18n.md +++ b/docs/i18n.md @@ -55,6 +55,21 @@ buildLocalizedPath({ slugs, locale: 'en', config }) // '/en/about' buildLocalizedPath({ slugs: { en: 'home' }, locale: 'en', config }) // '/en' ``` +> **Pułapka typu (TypeScript):** przy `locale: 'all'` Payload w RUNTIME zwraca +> zlokalizowane pole jako obiekt `{ pl, en }`, ale wygenerowane typy Payloada +> deklarują `doc.slug` jako `string` (typ nie odróżnia trybu `all`). `tsc` +> zgłosi więc niezgodność. Rozwiązanie — czyste rzutowanie na oczekiwany przez +> helper typ: +> ```ts +> const slugs = getLocalizedSlugs({ +> slugField: doc.slug as unknown as Record, +> config, +> }) +> ``` +> To nie hack — to pomost między statycznym typem (string) a rzeczywistym +> kształtem runtime (obiekt), którego generator typów Payloada nie modeluje. +> `as unknown as` jest tu poprawne, bo TS nie zna trybu `all`. + ### Przełącznik języka (bez 404) ```ts diff --git a/docs/seo.md b/docs/seo.md index aabb63f..fa13d42 100644 --- a/docs/seo.md +++ b/docs/seo.md @@ -217,6 +217,7 @@ export const { /* ... */, sitemap, robots } = createContentHelpers({ ```ts // app/sitemap.ts export { sitemap as default } from '@/lib/content' +export const dynamic = 'force-dynamic' // generuj w runtime, nie w buildzie // app/robots.ts export { robots as default } from '@/lib/content' @@ -227,6 +228,16 @@ całkiem w pluginie — Next tworzy te trasy wyłącznie z plików w `app/`, ska katalog projektu, nie node_modules. Ale re-eksport to maksimum redukcji: cała logika jest w pluginie. +> **Deploy kontenerowy (Coolify/Docker/Railway/CI) — WAŻNE:** `export const +> dynamic = 'force-dynamic'` w `app/sitemap.ts` jest KONIECZNE. Bez niego Next +> traktuje sitemap jako statyczny i prerenderuje go w `next build` — a to +> wywołuje Payload → bazę. Kontener budujący zwykle nie ma dostępu do sieci +> Docker, więc połączenie z bazą pada (`ENOTFOUND`) i build się wywala. Z +> `force-dynamic` sitemap generuje się w runtime, gdy baza jest dostępna. +> (Plugin dodatkowo łapie błąd bazy i zwraca pusty sitemap zamiast wywalić build +> — ale `force-dynamic` to właściwe rozwiązanie, nie poleganie na fallbacku.) +> Opcjonalnie `export const revalidate = 3600` — cache sitemap na godzinę. + Co zawiera sitemapa: - każdą stronę i wpis bloga, URL w domyślnym locale - `alternates.languages` → Next renderuje `` @@ -354,51 +365,88 @@ poprawnie: czytając z panelu/env, nie zaszywając wartości klienta. ### Web App Manifest (PWA) — jak zrobić DOBRZE +Zasada nadrzędna: **brak danych → POMIŃ pole, NIE zaszywaj wartości.** Manifest +jest ważny bez `name`? Nie — ale lepszy manifest bez nazwy niż z cudzą nazwą +klienta w fallbacku. Fallback z nazwą/kolorem klienta to ukryty hardkod. + ```ts // app/manifest.ts import type { MetadataRoute } from 'next' import { getCachedPayload } from '@/lib/content' import { getSiteSettings } from '@intecion/ipal-kit' +import { i18nConfig } from '@/i18n.config' 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 }) + const settings = await getSiteSettings(payload, { + locale: i18nConfig.defaultLocale 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 + const siteName = settings?.siteName?.trim() + // Ikona z panelu (favicon → logo). Dla PNG podaj KONKRETNY rozmiar z media + // (nie 'any' — 'any' jest tylko dla SVG). Bez ikony → pomiń pole icons. + const icon = settings?.favicon ?? settings?.logo + const iconEntry = + typeof icon === 'object' && icon?.url + ? (() => { + const isSvg = icon.mimeType === 'image/svg+xml' || icon.url.endsWith('.svg') + const size = + typeof icon.width === 'number' && typeof icon.height === 'number' + ? `${Math.min(icon.width, icon.height)}x${Math.min(icon.width, icon.height)}` + : '512x512' + return { + src: icon.url, + type: icon.mimeType ?? 'image/png', + sizes: isSvg ? 'any' : size, // 'any' tylko dla SVG + } + })() + : undefined + + // Buduj TYLKO z tego, co jest. Brak pola → nie ma go w manifeście (zamiast + // zaszytego fallbacku). start_url z configu, nie zaszyte '/pl'. return { - name: settings.siteName ?? '', - short_name: settings.siteName ?? '', // albo osobne pole, jeśli dodasz - start_url: '/', + ...(siteName ? { name: siteName, short_name: siteName } : {}), + start_url: `/${i18nConfig.defaultLocale}`, 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). + ...(iconEntry ? { icons: [iconEntry] } : {}), + // theme_color / background_color / description — TYLKO jeśli dodasz pola w + // panelu i je odczytasz. NIE zaszywaj '#0e1e24' ani opisu klienta. } } ``` -**Kontrast — czego NIE robić** (realny błąd z sesji): +**Kluczowe różnice od częstego błędu agenta:** +- **Brak fallbacku z nazwą klienta** — `siteName` puste → pomijamy `name`, nie + wstawiamy „Kancelaria X" na sztywno. Cudza nazwa w fallbacku = hardkod. +- **PNG dostaje konkretny `sizes`** z wymiarów media (nie `sizes: 'any'` — to + ten sam błąd co przy favicon; `any` tylko dla SVG). +- **Brak bloku `catch` z hardkodami** — jeśli boisz się błędu, opakuj samo + `getSiteSettings` i przy błędzie zwróć minimalny manifest (start_url + display), + BEZ zaszytej nazwy/kolorów. +- **start_url z i18nConfig**, nie zaszyte `/pl`. + +**Kontrast — czego NIE robić** (realne błędy z projektów): ```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 +// ŹLE — hardkod jawny (rcustomcars) +let name = 'R Custom Cars'; short_name: 'RCC' +background_color: '#08080a', theme_color: '#d4af37' +icons: [{ src: '/logo/rcc-logo.svg' }] // statyczna ścieżka + +// ŹLE — hardkod UKRYTY w fallbacku (kancelaria) +siteName || 'Kancelaria Adwokacka Adwokat Romuald Kędzierski' // cudza nazwa w || +sizes: 'any', type: mimeType // 'any' na PNG = źle +catch { return { name: 'Kancelaria...', theme_color: '#0e1e24' } } // hardkod w catch ``` +Fallback `|| 'Nazwa Klienta'` wygląda niewinnie, ale to hardkod — inny projekt +skopiuje i pokaże cudzą nazwę, gdy panel zawiedzie. Brak danych → pomiń pole. + 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. +`themeColor`, `manifestDescription` w SiteSettings (przez opcje pluginu +SiteSettingsFields) i czytaj z panelu. Wtedy redaktor je zmienia, nie są zaszyte. ### Inne ręczne rozszerzenia — ta sama zasada @@ -409,6 +457,55 @@ typu, itp.): - jeśli to uniwersalne i powtarzalne → rozważ zgłoszenie do pluginu zamiast ręcznie (patrz ANTIGRAVITY-ZASADY-AGENT.md A0) +## KRYTYCZNE: metadata w dla Google (htmlLimitedBots) + +**Największa pułapka SEO w Next.js — dotyczy KAŻDEGO projektu.** Dla dynamicznie +renderowanych stron (SSR) Next.js **streamuje metadata do ``**, nie ``, +i przenosi ją do head skryptem JS. Skutek: canonical, hreflang, title, favicon +lądują w body w surowym HTML. Crawlery, które nie wykonują JS (Screaming Frog, +część botów), widzą je poza head → ignorują → utrata SEO. + +Google *twierdzi*, że wykonuje JS i widzi przeniesione tagi, ale praktyka +(i audyty) pokazują realne problemy z indeksacją canonical. Bezpieczniej wymusić +metadata do head dla crawlerów. + +### Rozwiązanie — htmlLimitedBots w next.config + +```ts +// next.config.ts +const nextConfig: NextConfig = { + // Wymusza blocking metadata (canonical, hreflang, title, favicon) w + // dla crawlerów SEO — zamiast streamingu do . + htmlLimitedBots: + /Googlebot|Google-InspectionTool|Storebot-Google|Bingbot|Yandex|DuckDuckBot|Baiduspider|Screaming Frog|AhrefsBot|SemrushBot/i, + // ...reszta +} +``` + +`htmlLimitedBots` mówi Next: dla tych User-Agentów wyłącz streaming, wstaw +metadata do `` w surowym HTML (blocking). Użytkownicy dalej dostają +streaming (szybkie ładowanie); crawlery dostają poprawny head. + +### Objawy (że masz ten problem) + +- Screaming Frog: „canonical/hreflang/title outside " +- Search Console: „brak canonical", favicon nie pokazuje się (glob) +- W surowym HTML canonical/title są PO ``, na końcu body, ze skryptem + `document.querySelectorAll('body link[rel=icon]')...appendChild` + +### Weryfikacja + +```bash +# jako Googlebot — metadata MUSI być w +curl -A "Googlebot" https://twojadomena.pl/pl/strona | grep -o '.*' | grep canonical +# jako user — streaming (metadata w body — OK dla ludzi wykonujących JS) +curl -A "Mozilla/5.0" https://twojadomena.pl/pl/strona +``` + +Bez htmlLimitedBots ten sam problem dotknie favicon (glob w Google), canonical +(„User-declared canonical: None"), hreflang i title. Jedna linia w config +naprawia wszystko naraz. + ## SEO wielojęzyczne — hreflang, x-default, redirect roota Przekierowanie `/` → `/pl` (negocjacja locale) może wpływać na SEO. Kluczowe: @@ -536,4 +633,81 @@ Plugin dostarcza helpery — projekt MUSI je wpiąć i podać dane z panelu: - [ ] Jasne, opisowe tytuły stron (nie generyczne) - [ ] Logiczna hierarchia + linkowanie wewnętrzne z głównej -Dane WSZĘDZIE z panelu (siteName, nav, logo), nigdy zaszyte. \ No newline at end of file +Dane WSZĘDZIE z panelu (siteName, nav, logo), nigdy zaszyte. + +## noindex per strona (strony prawne, cienkie, wyniki wyszukiwania) + +Niektóre strony NIE powinny być w indeksie Google: polityki/regulamin (kanibalizują +frazy), strony z parametrami, wyniki wyszukiwania. Plugin wspiera to przez pole +`noindex` w meta SEO. + +```ts +// w danych strony (meta): noindex: true +// buildMetadata automatycznie doda robots: { index: false, follow: true } +``` + +`noindex, follow` — strona wypada z indeksu, ale linki dalej przekazują moc +(follow). Ustaw dla: +- polityka prywatności, regulamin, polityka cookies +- strony z parametrami kalkulatorów, filtrów +- wyniki wewnętrznej wyszukiwarki + +Redaktor zaznacza `noindex` w panelu (pole SEO strony), plugin generuje tag. +Alternatywnie: dodaj `noindex` do System Pages o rolach prawnych automatycznie. + +## robots.txt — blokada parametrów (crawl budget) + +URL-e z parametrami (`?meter=101-120m2`, `?s=fraza`) marnują budżet indeksowania — +Google skanuje dziesiątki pustych wariantów. Zablokuj je w robots: + +```ts +// app/robots.ts +import { buildRobots } from '@intecion/ipal-kit' +export default function robots() { + return buildRobots({ + baseUrl: process.env.NEXT_PUBLIC_SERVER_URL!, + disallow: ['/admin', '/api', '/*?meter=*', '/*?s=*'], // + parametry + }) +} +``` + +Wzorce `/*?param=*` odcinają parametryzowane URL-e. Realne z audytu: 55 +niezindeksowanych stron kalkulatora — blokada w robots by temu zapobiegła. + +## Local SEO — LocalBusiness, Service, FAQPage (structured data) + +Dla firm lokalnych (usługi + miasto) — trzy schematy zwiększające widoczność +w wynikach lokalnych i rich results. + +**LocalBusiness (map pack, wyniki lokalne)** — RAZ w root layout, z globala company: +```ts +import { buildLocalBusinessJsonLd } from '@intecion/ipal-kit' +const jsonLd = buildLocalBusinessJsonLd({ + name: company.name, url: baseUrl, telephone: company.phone, + address: company.address, openingHours: company.hours, + geo: company.geo, priceRange: '$$', +}) +``` +Najważniejsze dla „usługa + miasto". Dla konkretnego typu (Dentist, Plumber) +nadpisz `@type`. + +**Service (co strona oferuje)** — per strona usługowa: +```ts +import { buildServiceJsonLd } from '@intecion/ipal-kit' +const jsonLd = buildServiceJsonLd({ + name: 'Sprzątanie biur', providerName: company.name, + url: pageUrl, areaServed: 'Wrocław', +}) +``` + +**FAQPage (rich results FAQ)** — per strona z FAQ, z bloku FAQ w panelu: +```ts +import { buildFaqJsonLd } from '@intecion/ipal-kit' +const jsonLd = buildFaqJsonLd( + faqBlock.items.map(i => ({ question: i.question, answer: i.answer })) +) +``` +WAŻNE: Q&A musi odpowiadać widocznej treści strony (Google flaguje rozbieżność). +Nie wymyślaj pytań, których nie ma na stronie. + +Wszystkie: dane z panelu (company, bloki), jako `