Compare commits
16
Commits
v1.1.2
...
55a1e5bf8f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55a1e5bf8f | ||
|
|
33ae035adc | ||
|
|
87d575f335 | ||
|
|
e4329f76b1 | ||
|
|
fa4cf20022 | ||
|
|
2ecfc2e12d | ||
|
|
db2ac80a1e | ||
|
|
99490bfc4d | ||
|
|
fce17654f8 | ||
|
|
419207ac12 | ||
|
|
e30ac71044 | ||
|
|
781e348ded | ||
|
|
e16a18e488 | ||
|
|
7cd3cbaae5 | ||
|
|
60f07fddc9 | ||
|
|
068415849f |
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
import type { Field } from 'payload';
|
import type { Field } from 'payload';
|
||||||
/**
|
/**
|
||||||
* General site identity fields.
|
* General site identity fields.
|
||||||
* Consumed by SEO/OG (siteName, defaultShareImage) and frontend (logo, favicon).
|
* Consumed by SEO/OG (siteName, defaultShareImage) and frontend (logo).
|
||||||
*/
|
*/
|
||||||
export declare const generalFields: Field[];
|
export declare const generalFields: Field[];
|
||||||
|
|||||||
+11
-4
@@ -1,7 +1,7 @@
|
|||||||
import { validateFaviconField } from '../../../modules/seo/index.js';
|
import { validateFaviconField } from '../../../modules/seo/index.js';
|
||||||
/**
|
/**
|
||||||
* General site identity fields.
|
* General site identity fields.
|
||||||
* Consumed by SEO/OG (siteName, defaultShareImage) and frontend (logo, favicon).
|
* Consumed by SEO/OG (siteName, defaultShareImage) and frontend (logo).
|
||||||
*/ export const generalFields = [
|
*/ export const generalFields = [
|
||||||
{
|
{
|
||||||
name: 'siteName',
|
name: 'siteName',
|
||||||
@@ -12,6 +12,14 @@ import { validateFaviconField } from '../../../modules/seo/index.js';
|
|||||||
localized: true,
|
localized: true,
|
||||||
required: true
|
required: true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'siteDescription',
|
||||||
|
type: 'textarea',
|
||||||
|
admin: {
|
||||||
|
description: 'Fallback meta description when a page has none. Also used for llms.txt and Open Graph.'
|
||||||
|
},
|
||||||
|
localized: true
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'titleOrder',
|
name: 'titleOrder',
|
||||||
type: 'select',
|
type: 'select',
|
||||||
@@ -64,7 +72,7 @@ import { validateFaviconField } from '../../../modules/seo/index.js';
|
|||||||
name: 'logo',
|
name: 'logo',
|
||||||
type: 'upload',
|
type: 'upload',
|
||||||
admin: {
|
admin: {
|
||||||
description: 'Primary site logo. Also used for Organization structured data (buildOrganizationJsonLd).'
|
description: 'Primary site logo.'
|
||||||
},
|
},
|
||||||
relationTo: 'media'
|
relationTo: 'media'
|
||||||
},
|
},
|
||||||
@@ -83,8 +91,7 @@ import { validateFaviconField } from '../../../modules/seo/index.js';
|
|||||||
description: 'Square icon, PNG or SVG, min. 48×48px (Google requires this to show it in search). Rendered via buildIconsMetadata.'
|
description: 'Square icon, PNG or SVG, min. 48×48px (Google requires this to show it in search). Rendered via buildIconsMetadata.'
|
||||||
},
|
},
|
||||||
hooks: {
|
hooks: {
|
||||||
// Warns the editor at save time if the favicon is too small (<48×48) or
|
// Warn the editor if the favicon is too small / not square for Google.
|
||||||
// not square — Google won't display such favicons in search results.
|
|
||||||
beforeValidate: [
|
beforeValidate: [
|
||||||
validateFaviconField
|
validateFaviconField
|
||||||
]
|
]
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
{"version":3,"sources":["../../../../src/globals/SiteSettings/fields/general.ts"],"sourcesContent":["import type { Field } from 'payload'\n\nimport { validateFaviconField } from '../../../modules/seo/index.js'\n\n/**\n * General site identity fields.\n * Consumed by SEO/OG (siteName, defaultShareImage) and frontend (logo, favicon).\n */\nexport const generalFields: Field[] = [\n {\n name: 'siteName',\n type: 'text',\n admin: {\n description: 'Used in page titles and Open Graph metadata.',\n },\n localized: true,\n required: true,\n },\n {\n name: 'titleOrder',\n type: 'select',\n admin: {\n description: 'Which comes first in browser tabs.',\n },\n defaultValue: 'page-first',\n options: [\n { label: 'Page first — About Us | Acme', value: 'page-first' },\n { label: 'Site first — Acme | About Us', value: 'site-first' },\n ],\n },\n {\n name: 'titleSeparator',\n type: 'select',\n admin: {\n description: 'Separates the page title from the site name in browser tabs.',\n },\n defaultValue: '|',\n options: [\n { label: 'Pipe — Page | Site', value: '|' },\n { label: 'Dash — Page – Site', value: '–' },\n { label: 'Hyphen — Page - Site', value: '-' },\n { label: 'Bullet — Page · Site', value: '·' },\n { label: 'Slash — Page / Site', value: '/' },\n ],\n },\n {\n name: 'logo',\n type: 'upload',\n admin: {\n description:\n 'Primary site logo. Also used for Organization structured data (buildOrganizationJsonLd).',\n },\n relationTo: 'media',\n },\n {\n name: 'defaultShareImage',\n type: 'upload',\n admin: {\n description: 'Fallback Open Graph image when a page has none.',\n },\n relationTo: 'media',\n },\n {\n name: 'favicon',\n type: 'upload',\n admin: {\n description:\n 'Square icon, PNG or SVG, min. 48×48px (Google requires this to show it in search). Rendered via buildIconsMetadata.',\n },\n hooks: {\n // Warns the editor at save time if the favicon is too small (<48×48) or\n // not square — Google won't display such favicons in search results.\n beforeValidate: [validateFaviconField],\n },\n relationTo: 'media',\n },\n]\n"],"names":["validateFaviconField","generalFields","name","type","admin","description","localized","required","defaultValue","options","label","value","relationTo","hooks","beforeValidate"],"mappings":"AAEA,SAASA,oBAAoB,QAAQ,gCAA+B;AAEpE;;;CAGC,GACD,OAAO,MAAMC,gBAAyB;IACpC;QACEC,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;QACAC,WAAW;QACXC,UAAU;IACZ;IACA;QACEL,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;QACAG,cAAc;QACdC,SAAS;YACP;gBAAEC,OAAO;gBAAkCC,OAAO;YAAa;YAC/D;gBAAED,OAAO;gBAAkCC,OAAO;YAAa;SAChE;IACH;IACA;QACET,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;QACAG,cAAc;QACdC,SAAS;YACP;gBAAEC,OAAO;gBAAwBC,OAAO;YAAI;YAC5C;gBAAED,OAAO;gBAAwBC,OAAO;YAAI;YAC5C;gBAAED,OAAO;gBAA0BC,OAAO;YAAI;YAC9C;gBAAED,OAAO;gBAA0BC,OAAO;YAAI;YAC9C;gBAAED,OAAO;gBAAyBC,OAAO;YAAI;SAC9C;IACH;IACA;QACET,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aACE;QACJ;QACAO,YAAY;IACd;IACA;QACEV,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;QACAO,YAAY;IACd;IACA;QACEV,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aACE;QACJ;QACAQ,OAAO;YACL,wEAAwE;YACxE,qEAAqE;YACrEC,gBAAgB;gBAACd;aAAqB;QACxC;QACAY,YAAY;IACd;CACD,CAAA"}
|
{"version":3,"sources":["../../../../src/globals/SiteSettings/fields/general.ts"],"sourcesContent":["import type { Field } from 'payload'\n\nimport { validateFaviconField } from '../../../modules/seo/index.js'\n\n/**\n * General site identity fields.\n * Consumed by SEO/OG (siteName, defaultShareImage) and frontend (logo).\n */\nexport const generalFields: Field[] = [\n {\n name: 'siteName',\n type: 'text',\n admin: {\n description: 'Used in page titles and Open Graph metadata.',\n },\n localized: true,\n required: true,\n },\n {\n name: 'siteDescription',\n type: 'textarea',\n admin: {\n description:\n 'Fallback meta description when a page has none. Also used for llms.txt and Open Graph.',\n },\n localized: true,\n },\n {\n name: 'titleOrder',\n type: 'select',\n admin: {\n description: 'Which comes first in browser tabs.',\n },\n defaultValue: 'page-first',\n options: [\n { label: 'Page first — About Us | Acme', value: 'page-first' },\n { label: 'Site first — Acme | About Us', value: 'site-first' },\n ],\n },\n {\n name: 'titleSeparator',\n type: 'select',\n admin: {\n description: 'Separates the page title from the site name in browser tabs.',\n },\n defaultValue: '|',\n options: [\n { label: 'Pipe — Page | Site', value: '|' },\n { label: 'Dash — Page – Site', value: '–' },\n { label: 'Hyphen — Page - Site', value: '-' },\n { label: 'Bullet — Page · Site', value: '·' },\n { label: 'Slash — Page / Site', value: '/' },\n ],\n },\n {\n name: 'logo',\n type: 'upload',\n admin: {\n description: 'Primary site logo.',\n },\n relationTo: 'media',\n },\n {\n name: 'defaultShareImage',\n type: 'upload',\n admin: {\n description: 'Fallback Open Graph image when a page has none.',\n },\n relationTo: 'media',\n },\n {\n name: 'favicon',\n type: 'upload',\n admin: {\n description:\n 'Square icon, PNG or SVG, min. 48×48px (Google requires this to show it in search). Rendered via buildIconsMetadata.',\n },\n hooks: {\n // Warn the editor if the favicon is too small / not square for Google.\n beforeValidate: [validateFaviconField],\n },\n relationTo: 'media',\n },\n]\n"],"names":["validateFaviconField","generalFields","name","type","admin","description","localized","required","defaultValue","options","label","value","relationTo","hooks","beforeValidate"],"mappings":"AAEA,SAASA,oBAAoB,QAAQ,gCAA+B;AAEpE;;;CAGC,GACD,OAAO,MAAMC,gBAAyB;IACpC;QACEC,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;QACAC,WAAW;QACXC,UAAU;IACZ;IACA;QACEL,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aACE;QACJ;QACAC,WAAW;IACb;IACA;QACEJ,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;QACAG,cAAc;QACdC,SAAS;YACP;gBAAEC,OAAO;gBAAkCC,OAAO;YAAa;YAC/D;gBAAED,OAAO;gBAAkCC,OAAO;YAAa;SAChE;IACH;IACA;QACET,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;QACAG,cAAc;QACdC,SAAS;YACP;gBAAEC,OAAO;gBAAwBC,OAAO;YAAI;YAC5C;gBAAED,OAAO;gBAAwBC,OAAO;YAAI;YAC5C;gBAAED,OAAO;gBAA0BC,OAAO;YAAI;YAC9C;gBAAED,OAAO;gBAA0BC,OAAO;YAAI;YAC9C;gBAAED,OAAO;gBAAyBC,OAAO;YAAI;SAC9C;IACH;IACA;QACET,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;QACAO,YAAY;IACd;IACA;QACEV,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;QACAO,YAAY;IACd;IACA;QACEV,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aACE;QACJ;QACAQ,OAAO;YACL,uEAAuE;YACvEC,gBAAgB;gBAACd;aAAqB;QACxC;QACAY,YAAY;IACd;CACD,CAAA"}
|
||||||
Vendored
+1
-1
@@ -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 { getGlobal, getSiteIntegrations, getSiteSettings, SITE_INTEGRATIONS_SLUG, SITE_SETTINGS_SLUG, } from './modules/payload/index.js';
|
||||||
export { buildSecurityHeaders } from './modules/security/index.js';
|
export { buildSecurityHeaders } from './modules/security/index.js';
|
||||||
export type { BuildSecurityHeadersArgs, SecurityHeader } from './modules/security/index.js';
|
export type { BuildSecurityHeadersArgs, SecurityHeader } from './modules/security/index.js';
|
||||||
export { buildIconsMetadata, buildOrganizationJsonLd, validateFaviconField, } from './modules/seo/index.js';
|
export { buildArticleJsonLd, buildFaqJsonLd, buildIconsMetadata, buildLlmsTxt, buildLocalBusinessJsonLd, buildOrganizationJsonLd, buildServiceJsonLd, validateFaviconField, } from './modules/seo/index.js';
|
||||||
export { buildBreadcrumbJsonLd, buildSiteNavigationJsonLd, buildWebSiteJsonLd, } 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 type { PageMetadata, SeoMeta, SeoOption } from './modules/seo/index.js';
|
||||||
export { buildHreflangAlternates, buildMetadata, composeTitle } from './modules/seo/index.js';
|
export { buildHreflangAlternates, buildMetadata, composeTitle } from './modules/seo/index.js';
|
||||||
|
|||||||
Vendored
+1
-1
@@ -18,7 +18,7 @@ export { getNotificationTexts, NOTIFICATION_FALLBACK, resolveFormMessage } from
|
|||||||
export { ALL_SYSTEM_PAGE_ROLES, getSystemPagePath } from './modules/pages/index.js';
|
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 { getGlobal, getSiteIntegrations, getSiteSettings, SITE_INTEGRATIONS_SLUG, SITE_SETTINGS_SLUG } from './modules/payload/index.js';
|
||||||
export { buildSecurityHeaders } from './modules/security/index.js';
|
export { buildSecurityHeaders } from './modules/security/index.js';
|
||||||
export { buildIconsMetadata, buildOrganizationJsonLd, validateFaviconField } from './modules/seo/index.js';
|
export { buildArticleJsonLd, buildFaqJsonLd, buildIconsMetadata, buildLlmsTxt, buildLocalBusinessJsonLd, buildOrganizationJsonLd, buildServiceJsonLd, validateFaviconField } from './modules/seo/index.js';
|
||||||
// Structured data (schema.org JSON-LD) — brand/sitelink signals for Google.
|
// Structured data (schema.org JSON-LD) — brand/sitelink signals for Google.
|
||||||
// WebSite (+ optional SearchAction), BreadcrumbList (per page), SiteNavigation.
|
// WebSite (+ optional SearchAction), BreadcrumbList (per page), SiteNavigation.
|
||||||
export { buildBreadcrumbJsonLd, buildSiteNavigationJsonLd, buildWebSiteJsonLd } from './modules/seo/index.js';
|
export { buildBreadcrumbJsonLd, buildSiteNavigationJsonLd, buildWebSiteJsonLd } from './modules/seo/index.js';
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+16
-16
@@ -1,14 +1,8 @@
|
|||||||
import type { BasePayload, SanitizedConfig } from 'payload';
|
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 { I18nConfig } from '../i18n/index.js';
|
||||||
import type { RobotsRules, SitemapEntry } from '../seo/index.js';
|
import type { SitemapEntry, RobotsRules } from '../seo/index.js';
|
||||||
type CreateContentHelpersArgs = {
|
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).
|
* The client's payload config promise (the default export of payload.config).
|
||||||
* Passed in because the plugin never imports the client's config directly.
|
* Passed in because the plugin never imports the client's config directly.
|
||||||
@@ -16,15 +10,21 @@ type CreateContentHelpersArgs = {
|
|||||||
config: Promise<SanitizedConfig> | SanitizedConfig;
|
config: Promise<SanitizedConfig> | SanitizedConfig;
|
||||||
/** Archive-backed collections, same value as the plugin option. */
|
/** Archive-backed collections, same value as the plugin option. */
|
||||||
content?: ContentOption;
|
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`
|
* i18n config. Required only if you want the ready-made `sitemap` / `robots`
|
||||||
* handlers — they need the locale list to emit hreflang.
|
* handlers — they need the locale list to emit hreflang.
|
||||||
*/
|
*/
|
||||||
i18n?: I18nConfig;
|
i18n?: I18nConfig;
|
||||||
/** Pages collection slug. Defaults to 'pages'. */
|
/**
|
||||||
pagesSlug?: string;
|
* Absolute site origin for sitemap/robots URLs. Falls back to
|
||||||
/** SiteSettings global slug. Defaults to 'site-settings'. */
|
* NEXT_PUBLIC_SERVER_URL, then to a relative origin (which most crawlers
|
||||||
settingsSlug?: string;
|
* reject, so set one in production).
|
||||||
|
*/
|
||||||
|
baseUrl?: string;
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* Bundles the per-request data helpers a frontend needs — the same cached
|
* 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
|
* 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.
|
* 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<BasePayload>;
|
getCachedPayload: () => Promise<BasePayload>;
|
||||||
getConfiguredLocales: () => Promise<string[]>;
|
getConfiguredLocales: () => Promise<string[]>;
|
||||||
getEntries: (collection: string, locale: string, page: number, perPage: number) => Promise<ArchiveEntries>;
|
|
||||||
getSettings: (locale: string) => Promise<import("payload").JsonObject>;
|
getSettings: (locale: string) => Promise<import("payload").JsonObject>;
|
||||||
resolveRoute: (locale: string, segments: string[] | undefined, page: number) => Promise<null | ResolvedRoute>;
|
resolveRoute: (locale: string, segments: string[] | undefined, page: number) => Promise<ResolvedRoute | null>;
|
||||||
robots: () => RobotsRules;
|
getEntries: (collection: string, locale: string, page: number, perPage: number) => Promise<ArchiveEntries>;
|
||||||
sitemap: () => Promise<SitemapEntry[]>;
|
sitemap: () => Promise<SitemapEntry[]>;
|
||||||
|
robots: () => RobotsRules;
|
||||||
};
|
};
|
||||||
export {};
|
export {};
|
||||||
|
|||||||
+42
-22
@@ -1,7 +1,7 @@
|
|||||||
import { getPayload } from 'payload';
|
|
||||||
import { cache } from 'react';
|
import { cache } from 'react';
|
||||||
import { getArchiveEntries, resolveRoute as resolveRouteRaw } from '../content/index.js';
|
import { getPayload } from 'payload';
|
||||||
import { buildRobots, buildSitemapEntries } from '../seo/index.js';
|
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
|
* Bundles the per-request data helpers a frontend needs — the same cached
|
||||||
* wrappers every project was writing by hand (getPayload, settings, locale
|
* 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
|
* `resolveRoute` and `getEntries` are separate on purpose: routing decides what
|
||||||
* a URL is, fetching gets the listing. Metadata generation needs the first and
|
* 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.
|
* 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 origin = baseUrl ?? process.env.NEXT_PUBLIC_SERVER_URL ?? '';
|
||||||
const getCachedPayload = cache(async ()=>getPayload({
|
const getCachedPayload = cache(async ()=>getPayload({
|
||||||
config: await config
|
config: await config
|
||||||
@@ -40,29 +40,29 @@ import { buildRobots, buildSitemapEntries } from '../seo/index.js';
|
|||||||
const payload = await getCachedPayload();
|
const payload = await getCachedPayload();
|
||||||
return payload.findGlobal({
|
return payload.findGlobal({
|
||||||
slug: settingsSlug,
|
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)=>{
|
/** What does this URL point at? Routing only — no listing data. */ const resolveRoute = cache(async (locale, segments, page)=>{
|
||||||
const payload = await getCachedPayload();
|
const payload = await getCachedPayload();
|
||||||
return resolveRouteRaw({
|
return resolveRouteRaw({
|
||||||
content,
|
|
||||||
locale,
|
|
||||||
page,
|
|
||||||
pagesSlug,
|
|
||||||
payload,
|
payload,
|
||||||
|
locale,
|
||||||
segments,
|
segments,
|
||||||
|
page,
|
||||||
|
content,
|
||||||
|
pagesSlug,
|
||||||
settingsSlug
|
settingsSlug
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
/** One page of a collection's entries, for an archive listing. */ const getEntries = cache(async (collection, locale, page, perPage)=>{
|
/** One page of a collection's entries, for an archive listing. */ const getEntries = cache(async (collection, locale, page, perPage)=>{
|
||||||
const payload = await getCachedPayload();
|
const payload = await getCachedPayload();
|
||||||
return getArchiveEntries({
|
return getArchiveEntries({
|
||||||
|
payload,
|
||||||
collection,
|
collection,
|
||||||
locale,
|
locale,
|
||||||
page,
|
page,
|
||||||
payload,
|
|
||||||
perPage
|
perPage
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -73,19 +73,39 @@ import { buildRobots, buildSitemapEntries } from '../seo/index.js';
|
|||||||
* ```ts
|
* ```ts
|
||||||
* // app/sitemap.ts
|
* // app/sitemap.ts
|
||||||
* export { sitemap as default } from '@/lib/content'
|
* 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 ()=>{
|
*/ const sitemap = cache(async ()=>{
|
||||||
if (!i18n) {
|
if (!i18n) {
|
||||||
throw new Error('[ipal] createContentHelpers: pass `i18n` to use the sitemap handler.');
|
throw new Error('[ipal] createContentHelpers: pass `i18n` to use the sitemap handler.');
|
||||||
}
|
}
|
||||||
return buildSitemapEntries({
|
try {
|
||||||
baseUrl: origin,
|
return await buildSitemapEntries({
|
||||||
config: i18n,
|
payload: await getCachedPayload(),
|
||||||
content,
|
config: i18n,
|
||||||
pagesSlug,
|
baseUrl: origin,
|
||||||
payload: await getCachedPayload(),
|
content,
|
||||||
settingsSlug
|
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:
|
* Ready-made handler for Next's `app/robots.ts`. Re-export directly:
|
||||||
@@ -100,11 +120,11 @@ import { buildRobots, buildSitemapEntries } from '../seo/index.js';
|
|||||||
return {
|
return {
|
||||||
getCachedPayload,
|
getCachedPayload,
|
||||||
getConfiguredLocales,
|
getConfiguredLocales,
|
||||||
getEntries,
|
|
||||||
getSettings,
|
getSettings,
|
||||||
resolveRoute,
|
resolveRoute,
|
||||||
robots,
|
getEntries,
|
||||||
sitemap
|
sitemap,
|
||||||
|
robots
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
+22
-22
@@ -1,21 +1,21 @@
|
|||||||
import type { I18nConfig } from './types.js';
|
import type { I18nConfig } from '../i18n/index.js';
|
||||||
/**
|
/**
|
||||||
* Minimal request shape the middleware reads. Kept structural so the plugin
|
* Minimal request shape the middleware reads. Kept structural so the plugin
|
||||||
* doesn't hard-depend on next/server types; a Next.js `NextRequest` satisfies it.
|
* doesn't hard-depend on next/server types; a Next.js `NextRequest` satisfies it.
|
||||||
*/
|
*/
|
||||||
type MiddlewareRequest = {
|
type MiddlewareRequest = {
|
||||||
|
nextUrl: {
|
||||||
|
pathname: string;
|
||||||
|
search: string;
|
||||||
|
clone: () => URL;
|
||||||
|
};
|
||||||
cookies: {
|
cookies: {
|
||||||
get: (name: string) => {
|
get: (name: string) => {
|
||||||
value: string;
|
value: string;
|
||||||
} | undefined;
|
} | undefined;
|
||||||
};
|
};
|
||||||
headers: {
|
headers: {
|
||||||
get: (name: string) => null | string;
|
get: (name: string) => string | null;
|
||||||
};
|
|
||||||
nextUrl: {
|
|
||||||
clone: () => URL;
|
|
||||||
pathname: string;
|
|
||||||
search: string;
|
|
||||||
};
|
};
|
||||||
url: string;
|
url: string;
|
||||||
};
|
};
|
||||||
@@ -25,21 +25,23 @@ type MiddlewareRequest = {
|
|||||||
* `next` means let the request pass through untouched.
|
* `next` means let the request pass through untouched.
|
||||||
*/
|
*/
|
||||||
export type LocaleMiddlewareResult = {
|
export type LocaleMiddlewareResult = {
|
||||||
cookie?: {
|
|
||||||
name: string;
|
|
||||||
value: string;
|
|
||||||
};
|
|
||||||
location: string;
|
|
||||||
type: 'redirect';
|
|
||||||
} | {
|
|
||||||
cookie?: {
|
|
||||||
name: string;
|
|
||||||
value: string;
|
|
||||||
};
|
|
||||||
type: 'next';
|
type: 'next';
|
||||||
|
cookie?: {
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
} | {
|
||||||
|
type: 'redirect';
|
||||||
|
location: string;
|
||||||
|
cookie?: {
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
type CreateLocaleMiddlewareArgs = {
|
type CreateLocaleMiddlewareArgs = {
|
||||||
config: I18nConfig;
|
config: I18nConfig;
|
||||||
|
/** Cookie name for the locale choice. Defaults to LOCALE_COOKIE_NAME. */
|
||||||
|
cookieName?: string;
|
||||||
/**
|
/**
|
||||||
* Consent category that gates *persisting* the locale cookie. The locale is
|
* Consent category that gates *persisting* the locale cookie. The locale is
|
||||||
* always detected (routing works regardless), but the choice is only written
|
* always detected (routing works regardless), but the choice is only written
|
||||||
@@ -47,11 +49,9 @@ type CreateLocaleMiddlewareArgs = {
|
|||||||
* 'functional'. Pass 'necessary' to always persist (treat locale as strictly
|
* 'functional'. Pass 'necessary' to always persist (treat locale as strictly
|
||||||
* necessary), which restores the pre-consent behaviour.
|
* necessary), which restores the pre-consent behaviour.
|
||||||
*/
|
*/
|
||||||
consentCategory?: 'functional' | 'necessary';
|
consentCategory?: 'necessary' | 'functional';
|
||||||
/** Name of the consent cookie to read. Defaults to CONSENT_COOKIE. */
|
/** Name of the consent cookie to read. Defaults to CONSENT_COOKIE. */
|
||||||
consentCookieName?: string;
|
consentCookieName?: string;
|
||||||
/** Cookie name for the locale choice. Defaults to LOCALE_COOKIE_NAME. */
|
|
||||||
cookieName?: string;
|
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* Builds locale-routing logic for Next.js middleware.
|
* Builds locale-routing logic for Next.js middleware.
|
||||||
@@ -80,7 +80,7 @@ type CreateLocaleMiddlewareArgs = {
|
|||||||
* return res
|
* return res
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
export declare function createLocaleMiddleware({ config, consentCategory, consentCookieName, cookieName, }: CreateLocaleMiddlewareArgs): (request: MiddlewareRequest) => LocaleMiddlewareResult;
|
export declare function createLocaleMiddleware({ config, cookieName, consentCategory, consentCookieName, }: CreateLocaleMiddlewareArgs): (request: MiddlewareRequest) => LocaleMiddlewareResult;
|
||||||
/**
|
/**
|
||||||
* Default Next.js middleware matcher: run on everything except API routes, the
|
* Default Next.js middleware matcher: run on everything except API routes, the
|
||||||
* admin panel, Next internals, and files with an extension (static assets).
|
* admin panel, Next internals, and files with an extension (static assets).
|
||||||
|
|||||||
+15
-7
@@ -1,5 +1,5 @@
|
|||||||
|
import { negotiateLocale, isValidLocale, LOCALE_COOKIE_NAME } from '../i18n/index.js';
|
||||||
import { CONSENT_COOKIE, parseConsent } from '../consent/storage.js';
|
import { CONSENT_COOKIE, parseConsent } from '../consent/storage.js';
|
||||||
import { isValidLocale, LOCALE_COOKIE_NAME, negotiateLocale } from '../i18n/index.js';
|
|
||||||
/**
|
/**
|
||||||
* First path segment of a URL pathname, or '' for root.
|
* First path segment of a URL pathname, or '' for root.
|
||||||
* '/pl/o-nas' → 'pl', '/o-nas' → 'o-nas', '/' → ''.
|
* '/pl/o-nas' → 'pl', '/o-nas' → 'o-nas', '/' → ''.
|
||||||
@@ -32,18 +32,26 @@ import { isValidLocale, LOCALE_COOKIE_NAME, negotiateLocale } from '../i18n/inde
|
|||||||
* if (r.cookie) res.cookies.set(r.cookie.name, r.cookie.value)
|
* if (r.cookie) res.cookies.set(r.cookie.name, r.cookie.value)
|
||||||
* return res
|
* return res
|
||||||
* }
|
* }
|
||||||
*/ export function createLocaleMiddleware({ config, consentCategory = 'functional', consentCookieName = CONSENT_COOKIE, cookieName = LOCALE_COOKIE_NAME }) {
|
*/ export function createLocaleMiddleware({ config, cookieName = LOCALE_COOKIE_NAME, consentCategory = 'functional', consentCookieName = CONSENT_COOKIE }) {
|
||||||
// Whether the locale cookie may be written: 'necessary' is always granted;
|
// Whether the locale cookie may be written: 'necessary' is always granted;
|
||||||
// 'functional' (default) requires the visitor to have consented.
|
// 'functional' (default) requires the visitor to have consented.
|
||||||
function mayPersistLocale(request) {
|
function mayPersistLocale(request) {
|
||||||
if (consentCategory === 'necessary') {
|
if (consentCategory === 'necessary') return true;
|
||||||
return true;
|
|
||||||
}
|
|
||||||
const consent = parseConsent(request.cookies.get(consentCookieName)?.value);
|
const consent = parseConsent(request.cookies.get(consentCookieName)?.value);
|
||||||
return consent?.[consentCategory] === true;
|
return consent?.[consentCategory] === true;
|
||||||
}
|
}
|
||||||
return function localeMiddleware(request) {
|
return function localeMiddleware(request) {
|
||||||
const { pathname } = request.nextUrl;
|
const { pathname } = request.nextUrl;
|
||||||
|
// Single-locale sites have no /pl, /en prefix and no negotiation — one
|
||||||
|
// language, no redirect. The middleware becomes a pass-through: paths stay
|
||||||
|
// as-is (/o-nas), nothing to detect or persist. (Projects that are truly
|
||||||
|
// single-locale usually don't even mount this middleware, but guarding here
|
||||||
|
// makes it safe if they do.)
|
||||||
|
if (config.locales.length === 1) {
|
||||||
|
return {
|
||||||
|
type: 'next'
|
||||||
|
};
|
||||||
|
}
|
||||||
// Already locale-prefixed (e.g. the visitor switched language by
|
// Already locale-prefixed (e.g. the visitor switched language by
|
||||||
// navigating to /en). Routing is fine — but if the URL's locale differs
|
// navigating to /en). Routing is fine — but if the URL's locale differs
|
||||||
// from the stored cookie, the visitor is *choosing* a language, and we
|
// from the stored cookie, the visitor is *choosing* a language, and we
|
||||||
@@ -68,9 +76,9 @@ import { isValidLocale, LOCALE_COOKIE_NAME, negotiateLocale } from '../i18n/inde
|
|||||||
}
|
}
|
||||||
// Resolve the locale to use
|
// Resolve the locale to use
|
||||||
const locale = negotiateLocale({
|
const locale = negotiateLocale({
|
||||||
|
cookieLocale: request.cookies.get(cookieName)?.value ?? null,
|
||||||
acceptLanguage: request.headers.get('accept-language'),
|
acceptLanguage: request.headers.get('accept-language'),
|
||||||
config,
|
config
|
||||||
cookieLocale: request.cookies.get(cookieName)?.value ?? null
|
|
||||||
});
|
});
|
||||||
// Redirect to the locale-prefixed path, preserving the rest
|
// Redirect to the locale-prefixed path, preserving the rest
|
||||||
const url = request.nextUrl.clone();
|
const url = request.nextUrl.clone();
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+19
-13
@@ -8,14 +8,22 @@ import type { I18nConfig } from './types.js';
|
|||||||
*/
|
*/
|
||||||
export type LocalizedSlugs = Record<string, string>;
|
export type LocalizedSlugs = Record<string, string>;
|
||||||
type BuildPathArgs = {
|
type BuildPathArgs = {
|
||||||
config: I18nConfig;
|
/** slug per locale, e.g. { pl: 'strona-glowna', en: 'home' } */
|
||||||
/**
|
slugs: LocalizedSlugs;
|
||||||
* Slug that represents the site root (served at /{locale} with no trailing
|
|
||||||
* segment). Defaults to 'home'. Matched against the slug in the target locale.
|
|
||||||
*/
|
|
||||||
homeSlug?: string;
|
|
||||||
/** Target locale to build the path for */
|
/** Target locale to build the path for */
|
||||||
locale: string;
|
locale: string;
|
||||||
|
config: I18nConfig;
|
||||||
|
/**
|
||||||
|
* Slug(s) representing the site root (served at /{locale} with no trailing
|
||||||
|
* segment). Defaults to 'home'. Matched against the slug in the target locale.
|
||||||
|
*
|
||||||
|
* Can be a single string (same home slug in every locale) OR a per-locale map
|
||||||
|
* (`{ pl: 'strona-glowna', de: 'startseite', en: 'home' }`). The map form is
|
||||||
|
* REQUIRED for multilingual homepages whose slug differs per language —
|
||||||
|
* otherwise the home page collapses to '/pl' but '/de/startseite' stays
|
||||||
|
* un-collapsed, breaking hreflang return tags (a real GSC error).
|
||||||
|
*/
|
||||||
|
homeSlug?: string | Record<string, string>;
|
||||||
/**
|
/**
|
||||||
* Localized segment the document lives under, e.g.
|
* Localized segment the document lives under, e.g.
|
||||||
* `{ pl: 'artykuly', en: 'articles' }` → /pl/artykuly/moj-post.
|
* `{ pl: 'artykuly', en: 'articles' }` → /pl/artykuly/moj-post.
|
||||||
@@ -25,8 +33,6 @@ type BuildPathArgs = {
|
|||||||
* A document under a prefix is never the home page, so homeSlug is ignored.
|
* A document under a prefix is never the home page, so homeSlug is ignored.
|
||||||
*/
|
*/
|
||||||
prefix?: LocalizedSlugs;
|
prefix?: LocalizedSlugs;
|
||||||
/** slug per locale, e.g. { pl: 'strona-glowna', en: 'home' } */
|
|
||||||
slugs: LocalizedSlugs;
|
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* Builds a locale-prefixed path for a document in a target locale.
|
* Builds a locale-prefixed path for a document in a target locale.
|
||||||
@@ -50,13 +56,13 @@ type BuildPathArgs = {
|
|||||||
* })
|
* })
|
||||||
* // → '/en/articles/my-post'
|
* // → '/en/articles/my-post'
|
||||||
*/
|
*/
|
||||||
export declare function buildLocalizedPath({ config, homeSlug, locale, prefix, slugs, }: BuildPathArgs): string | undefined;
|
export declare function buildLocalizedPath({ slugs, locale, config, homeSlug, prefix, }: BuildPathArgs): string | undefined;
|
||||||
type SwitchLocaleArgs = {
|
type SwitchLocaleArgs = {
|
||||||
config: I18nConfig;
|
|
||||||
homeSlug?: string;
|
|
||||||
prefix?: LocalizedSlugs;
|
|
||||||
slugs: LocalizedSlugs;
|
slugs: LocalizedSlugs;
|
||||||
targetLocale: string;
|
targetLocale: string;
|
||||||
|
config: I18nConfig;
|
||||||
|
homeSlug?: string | Record<string, string>;
|
||||||
|
prefix?: LocalizedSlugs;
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* Resolves the equivalent path for the same document in a different locale —
|
* Resolves the equivalent path for the same document in a different locale —
|
||||||
@@ -66,5 +72,5 @@ type SwitchLocaleArgs = {
|
|||||||
* falls back to the archive it belongs to (/en/articles) if there is one, and
|
* falls back to the archive it belongs to (/en/articles) if there is one, and
|
||||||
* to the locale root otherwise — the closest place the visitor would want.
|
* to the locale root otherwise — the closest place the visitor would want.
|
||||||
*/
|
*/
|
||||||
export declare function switchLocalePath({ config, homeSlug, prefix, slugs, targetLocale, }: SwitchLocaleArgs): string;
|
export declare function switchLocalePath({ slugs, targetLocale, config, homeSlug, prefix, }: SwitchLocaleArgs): string;
|
||||||
export {};
|
export {};
|
||||||
|
|||||||
Vendored
+23
-15
@@ -20,7 +20,7 @@ import { isValidLocale } from './helpers.js';
|
|||||||
* config,
|
* config,
|
||||||
* })
|
* })
|
||||||
* // → '/en/articles/my-post'
|
* // → '/en/articles/my-post'
|
||||||
*/ export function buildLocalizedPath({ config, homeSlug = 'home', locale, prefix, slugs }) {
|
*/ export function buildLocalizedPath({ slugs, locale, config, homeSlug = 'home', prefix }) {
|
||||||
if (!isValidLocale(locale, config)) {
|
if (!isValidLocale(locale, config)) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -28,6 +28,12 @@ import { isValidLocale } from './helpers.js';
|
|||||||
if (!slug) {
|
if (!slug) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
// Single-locale sites have no /pl, /en prefix — the language segment is
|
||||||
|
// dropped entirely (path is /o-nas, not /pl/o-nas). Detected automatically:
|
||||||
|
// one configured locale means one language, so no prefix is needed. The
|
||||||
|
// project's folder structure matches (app/[[...slug]] without [locale]).
|
||||||
|
const singleLocale = config.locales.length === 1;
|
||||||
|
const localeSegment = singleLocale ? '' : `/${locale}`;
|
||||||
if (prefix) {
|
if (prefix) {
|
||||||
const segment = prefix[locale];
|
const segment = prefix[locale];
|
||||||
// No archive slug in this locale means the entry is unreachable there —
|
// No archive slug in this locale means the entry is unreachable there —
|
||||||
@@ -36,12 +42,18 @@ import { isValidLocale } from './helpers.js';
|
|||||||
if (!segment) {
|
if (!segment) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
return `/${locale}/${segment}/${slug}`;
|
return `${localeSegment}/${segment}/${slug}`;
|
||||||
}
|
}
|
||||||
if (slug === homeSlug) {
|
// Resolve the home slug for THIS locale. With a per-locale map, each language
|
||||||
return `/${locale}`;
|
// has its own home slug (pl: 'strona-glowna', de: 'startseite'), so the check
|
||||||
|
// below correctly collapses each language's home to its root — instead of only
|
||||||
|
// the current-language slug matching and the others staying un-collapsed.
|
||||||
|
const targetHomeSlug = typeof homeSlug === 'object' ? homeSlug[locale] : homeSlug;
|
||||||
|
if (slug === targetHomeSlug) {
|
||||||
|
// Home collapses to the root: '/' for single-locale, '/pl' otherwise.
|
||||||
|
return localeSegment || '/';
|
||||||
}
|
}
|
||||||
return `/${locale}/${slug}`;
|
return `${localeSegment}/${slug}`;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Resolves the equivalent path for the same document in a different locale —
|
* Resolves the equivalent path for the same document in a different locale —
|
||||||
@@ -50,21 +62,17 @@ import { isValidLocale } from './helpers.js';
|
|||||||
* Never dead-ends on a 404. When the document has no slug in the target locale,
|
* Never dead-ends on a 404. When the document has no slug in the target locale,
|
||||||
* falls back to the archive it belongs to (/en/articles) if there is one, and
|
* falls back to the archive it belongs to (/en/articles) if there is one, and
|
||||||
* to the locale root otherwise — the closest place the visitor would want.
|
* to the locale root otherwise — the closest place the visitor would want.
|
||||||
*/ export function switchLocalePath({ config, homeSlug = 'home', prefix, slugs, targetLocale }) {
|
*/ export function switchLocalePath({ slugs, targetLocale, config, homeSlug = 'home', prefix }) {
|
||||||
const path = buildLocalizedPath({
|
const path = buildLocalizedPath({
|
||||||
|
slugs,
|
||||||
|
locale: targetLocale,
|
||||||
config,
|
config,
|
||||||
homeSlug,
|
homeSlug,
|
||||||
locale: targetLocale,
|
prefix
|
||||||
prefix,
|
|
||||||
slugs
|
|
||||||
});
|
});
|
||||||
if (path) {
|
if (path) return path;
|
||||||
return path;
|
|
||||||
}
|
|
||||||
const archiveSegment = prefix?.[targetLocale];
|
const archiveSegment = prefix?.[targetLocale];
|
||||||
if (archiveSegment) {
|
if (archiveSegment) return `/${targetLocale}/${archiveSegment}`;
|
||||||
return `/${targetLocale}/${archiveSegment}`;
|
|
||||||
}
|
|
||||||
return `/${targetLocale}`;
|
return `/${targetLocale}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -16,7 +16,7 @@ type GetSystemPagePathArgs = {
|
|||||||
* page's slug in the target locale equals this, the path collapses to the
|
* page's slug in the target locale equals this, the path collapses to the
|
||||||
* locale root (/pl, /en).
|
* locale root (/pl, /en).
|
||||||
*/
|
*/
|
||||||
homeSlug?: string;
|
homeSlug?: string | Record<string, string>;
|
||||||
/** Target locale to build the path for. */
|
/** Target locale to build the path for. */
|
||||||
locale: string;
|
locale: string;
|
||||||
/**
|
/**
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
{"version":3,"sources":["../../../src/modules/pages/getSystemPagePath.ts"],"sourcesContent":["import type { I18nConfig } from '../i18n/index.js'\nimport type { SystemPageRole } from './types.js'\n\nimport { buildLocalizedPath, getLocalizedSlugs } from '../i18n/index.js'\n\n/**\n * The shape we need from an assigned system-page document: its localized slug\n * field, as returned by Payload when the parent is read with `locale: 'all'`.\n *\n * The plugin doesn't know the client's Pages type, so it depends only on this\n * minimal contract.\n */\ntype AssignedPage = {\n slug?: null | Record<string, unknown>\n}\n\ntype GetSystemPagePathArgs = {\n config: I18nConfig\n /**\n * Slug that represents the site root. Defaults to 'home'. When the assigned\n * page's slug in the target locale equals this, the path collapses to the\n * locale root (/pl, /en).\n */\n homeSlug?: string\n /** Target locale to build the path for. */\n locale: string\n /**\n * The resolved system-page assignment from SiteSettings — the related\n * document object (not just an ID), read with `locale: 'all'` so its slug\n * is a locale→value map. Pass null/undefined if the role is unassigned.\n */\n page: AssignedPage | null | undefined\n}\n\n/**\n * Resolves a system-page assignment to a locale-aware path.\n *\n * Bridges the Pages module (which document plays a role) and the i18n module\n * (how that document's localized slug becomes a URL). This is what powers\n * \"visit /pl → serve the homepage\": read SiteSettings.homepage, pass the\n * related document here, get /pl/strona-glowna (or /pl if it's the home slug).\n *\n * Returns undefined when the role is unassigned or the assigned page has no\n * slug in the target locale — the caller decides the fallback (e.g. 404,\n * redirect to default locale).\n *\n * @example\n * const settings = await payload.findGlobal({ slug: 'site-settings', locale: 'all', depth: 1 })\n * getSystemPagePath({ page: settings.homepage, locale: 'pl', config })\n * // → '/pl' (home slug collapses to root)\n * getSystemPagePath({ page: settings.privacyPolicy, locale: 'en', config })\n * // → '/en/privacy-policy'\n */\nexport function getSystemPagePath({\n config,\n homeSlug = 'home',\n locale,\n page,\n}: GetSystemPagePathArgs): string | undefined {\n if (!page?.slug) {\n return undefined\n }\n\n const slugs = getLocalizedSlugs({ config, slugField: page.slug })\n return buildLocalizedPath({ config, homeSlug, locale, slugs })\n}\n"],"names":["buildLocalizedPath","getLocalizedSlugs","getSystemPagePath","config","homeSlug","locale","page","slug","undefined","slugs","slugField"],"mappings":"AAGA,SAASA,kBAAkB,EAAEC,iBAAiB,QAAQ,mBAAkB;AA+BxE;;;;;;;;;;;;;;;;;;CAkBC,GACD,OAAO,SAASC,kBAAkB,EAChCC,MAAM,EACNC,WAAW,MAAM,EACjBC,MAAM,EACNC,IAAI,EACkB;IACtB,IAAI,CAACA,MAAMC,MAAM;QACf,OAAOC;IACT;IAEA,MAAMC,QAAQR,kBAAkB;QAAEE;QAAQO,WAAWJ,KAAKC,IAAI;IAAC;IAC/D,OAAOP,mBAAmB;QAAEG;QAAQC;QAAUC;QAAQI;IAAM;AAC9D"}
|
{"version":3,"sources":["../../../src/modules/pages/getSystemPagePath.ts"],"sourcesContent":["import type { I18nConfig } from '../i18n/index.js'\nimport type { SystemPageRole } from './types.js'\n\nimport { buildLocalizedPath, getLocalizedSlugs } from '../i18n/index.js'\n\n/**\n * The shape we need from an assigned system-page document: its localized slug\n * field, as returned by Payload when the parent is read with `locale: 'all'`.\n *\n * The plugin doesn't know the client's Pages type, so it depends only on this\n * minimal contract.\n */\ntype AssignedPage = {\n slug?: null | Record<string, unknown>\n}\n\ntype GetSystemPagePathArgs = {\n config: I18nConfig\n /**\n * Slug that represents the site root. Defaults to 'home'. When the assigned\n * page's slug in the target locale equals this, the path collapses to the\n * locale root (/pl, /en).\n */\n homeSlug?: string | Record<string, string>\n /** Target locale to build the path for. */\n locale: string\n /**\n * The resolved system-page assignment from SiteSettings — the related\n * document object (not just an ID), read with `locale: 'all'` so its slug\n * is a locale→value map. Pass null/undefined if the role is unassigned.\n */\n page: AssignedPage | null | undefined\n}\n\n/**\n * Resolves a system-page assignment to a locale-aware path.\n *\n * Bridges the Pages module (which document plays a role) and the i18n module\n * (how that document's localized slug becomes a URL). This is what powers\n * \"visit /pl → serve the homepage\": read SiteSettings.homepage, pass the\n * related document here, get /pl/strona-glowna (or /pl if it's the home slug).\n *\n * Returns undefined when the role is unassigned or the assigned page has no\n * slug in the target locale — the caller decides the fallback (e.g. 404,\n * redirect to default locale).\n *\n * @example\n * const settings = await payload.findGlobal({ slug: 'site-settings', locale: 'all', depth: 1 })\n * getSystemPagePath({ page: settings.homepage, locale: 'pl', config })\n * // → '/pl' (home slug collapses to root)\n * getSystemPagePath({ page: settings.privacyPolicy, locale: 'en', config })\n * // → '/en/privacy-policy'\n */\nexport function getSystemPagePath({\n config,\n homeSlug = 'home',\n locale,\n page,\n}: GetSystemPagePathArgs): string | undefined {\n if (!page?.slug) {\n return undefined\n }\n\n const slugs = getLocalizedSlugs({ config, slugField: page.slug })\n return buildLocalizedPath({ config, homeSlug, locale, slugs })\n}\n"],"names":["buildLocalizedPath","getLocalizedSlugs","getSystemPagePath","config","homeSlug","locale","page","slug","undefined","slugs","slugField"],"mappings":"AAGA,SAASA,kBAAkB,EAAEC,iBAAiB,QAAQ,mBAAkB;AA+BxE;;;;;;;;;;;;;;;;;;CAkBC,GACD,OAAO,SAASC,kBAAkB,EAChCC,MAAM,EACNC,WAAW,MAAM,EACjBC,MAAM,EACNC,IAAI,EACkB;IACtB,IAAI,CAACA,MAAMC,MAAM;QACf,OAAOC;IACT;IAEA,MAAMC,QAAQR,kBAAkB;QAAEE;QAAQO,WAAWJ,KAAKC,IAAI;IAAC;IAC/D,OAAOP,mBAAmB;QAAEG;QAAQC;QAAUC;QAAQI;IAAM;AAC9D"}
|
||||||
+69
@@ -0,0 +1,69 @@
|
|||||||
|
type MediaLike = {
|
||||||
|
url?: string | null;
|
||||||
|
} | null | undefined;
|
||||||
|
type ArticleJsonLdArgs = {
|
||||||
|
/** Article headline (the title). */
|
||||||
|
headline: string;
|
||||||
|
/** Absolute URL of the article. */
|
||||||
|
url: string;
|
||||||
|
description?: string;
|
||||||
|
/** Cover/hero image. */
|
||||||
|
image?: MediaLike;
|
||||||
|
/** ISO date the article was first published, e.g. '2026-01-15'. */
|
||||||
|
datePublished?: string;
|
||||||
|
/** ISO date of the last modification. Defaults to datePublished. */
|
||||||
|
dateModified?: string;
|
||||||
|
/** Author name (person or org). */
|
||||||
|
author?: string;
|
||||||
|
/** Publisher (usually the site/organization) name. */
|
||||||
|
publisherName?: string;
|
||||||
|
/** Publisher logo (for the Article rich result). */
|
||||||
|
publisherLogo?: MediaLike;
|
||||||
|
/** 'Article' (default), 'BlogPosting', or 'NewsArticle'. */
|
||||||
|
type?: 'Article' | 'BlogPosting' | 'NewsArticle';
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Builds Article / BlogPosting JSON-LD (schema.org) for blog posts and articles.
|
||||||
|
* Enables article rich results in Google (headline, date, author, image). Pairs
|
||||||
|
* with the plugin's content collections (blog/archives).
|
||||||
|
*
|
||||||
|
* Per-article (each post emits its own), data from the document + panel:
|
||||||
|
*
|
||||||
|
* import { buildArticleJsonLd } from '@intecion/ipal-kit'
|
||||||
|
* const jsonLd = buildArticleJsonLd({
|
||||||
|
* headline: post.title, url: postUrl, description: post.excerpt,
|
||||||
|
* image: post.coverImage, datePublished: post.publishedAt,
|
||||||
|
* author: post.author, publisherName: settings.siteName,
|
||||||
|
* publisherLogo: settings.logo, type: 'BlogPosting',
|
||||||
|
* })
|
||||||
|
*
|
||||||
|
* All data from the document/panel — nothing hardcoded. Google requires headline
|
||||||
|
* and dates for the rich result; supply them from the post's real fields.
|
||||||
|
*/
|
||||||
|
export declare function buildArticleJsonLd({ headline, url, description, image, datePublished, dateModified, author, publisherName, publisherLogo, type, }: ArticleJsonLdArgs): {
|
||||||
|
publisher?: {
|
||||||
|
logo?: {
|
||||||
|
'@type': string;
|
||||||
|
url: string | undefined;
|
||||||
|
} | undefined;
|
||||||
|
'@type': string;
|
||||||
|
name: string;
|
||||||
|
} | undefined;
|
||||||
|
author?: {
|
||||||
|
'@type': string;
|
||||||
|
name: string;
|
||||||
|
} | undefined;
|
||||||
|
dateModified?: string | undefined;
|
||||||
|
datePublished?: string | undefined;
|
||||||
|
image?: string | undefined;
|
||||||
|
description?: string | undefined;
|
||||||
|
'@context': string;
|
||||||
|
'@type': "Article" | "BlogPosting" | "NewsArticle";
|
||||||
|
headline: string;
|
||||||
|
url: string;
|
||||||
|
mainEntityOfPage: {
|
||||||
|
'@type': string;
|
||||||
|
'@id': string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
export {};
|
||||||
+64
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* Builds Article / BlogPosting JSON-LD (schema.org) for blog posts and articles.
|
||||||
|
* Enables article rich results in Google (headline, date, author, image). Pairs
|
||||||
|
* with the plugin's content collections (blog/archives).
|
||||||
|
*
|
||||||
|
* Per-article (each post emits its own), data from the document + panel:
|
||||||
|
*
|
||||||
|
* import { buildArticleJsonLd } from '@intecion/ipal-kit'
|
||||||
|
* const jsonLd = buildArticleJsonLd({
|
||||||
|
* headline: post.title, url: postUrl, description: post.excerpt,
|
||||||
|
* image: post.coverImage, datePublished: post.publishedAt,
|
||||||
|
* author: post.author, publisherName: settings.siteName,
|
||||||
|
* publisherLogo: settings.logo, type: 'BlogPosting',
|
||||||
|
* })
|
||||||
|
*
|
||||||
|
* All data from the document/panel — nothing hardcoded. Google requires headline
|
||||||
|
* and dates for the rich result; supply them from the post's real fields.
|
||||||
|
*/ export function buildArticleJsonLd({ headline, url, description, image, datePublished, dateModified, author, publisherName, publisherLogo, type = 'Article' }) {
|
||||||
|
const imageUrl = image?.url;
|
||||||
|
const logoUrl = publisherLogo?.url;
|
||||||
|
const abs = (u)=>u ? u.startsWith('http') ? u : `${url.split('/').slice(0, 3).join('/')}${u}` : undefined;
|
||||||
|
return {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': type,
|
||||||
|
headline,
|
||||||
|
url,
|
||||||
|
mainEntityOfPage: {
|
||||||
|
'@type': 'WebPage',
|
||||||
|
'@id': url
|
||||||
|
},
|
||||||
|
...description ? {
|
||||||
|
description
|
||||||
|
} : {},
|
||||||
|
...imageUrl ? {
|
||||||
|
image: abs(imageUrl)
|
||||||
|
} : {},
|
||||||
|
...datePublished ? {
|
||||||
|
datePublished
|
||||||
|
} : {},
|
||||||
|
...dateModified || datePublished ? {
|
||||||
|
dateModified: dateModified ?? datePublished
|
||||||
|
} : {},
|
||||||
|
...author ? {
|
||||||
|
author: {
|
||||||
|
'@type': 'Person',
|
||||||
|
name: author
|
||||||
|
}
|
||||||
|
} : {},
|
||||||
|
...publisherName ? {
|
||||||
|
publisher: {
|
||||||
|
'@type': 'Organization',
|
||||||
|
name: publisherName,
|
||||||
|
...logoUrl ? {
|
||||||
|
logo: {
|
||||||
|
'@type': 'ImageObject',
|
||||||
|
url: abs(logoUrl)
|
||||||
|
}
|
||||||
|
} : {}
|
||||||
|
}
|
||||||
|
} : {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
//# sourceMappingURL=buildArticleJsonLd.js.map
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"sources":["../../../src/modules/seo/buildArticleJsonLd.ts"],"sourcesContent":["type MediaLike = { url?: string | null } | null | undefined\n\ntype ArticleJsonLdArgs = {\n /** Article headline (the title). */\n headline: string\n /** Absolute URL of the article. */\n url: string\n description?: string\n /** Cover/hero image. */\n image?: MediaLike\n /** ISO date the article was first published, e.g. '2026-01-15'. */\n datePublished?: string\n /** ISO date of the last modification. Defaults to datePublished. */\n dateModified?: string\n /** Author name (person or org). */\n author?: string\n /** Publisher (usually the site/organization) name. */\n publisherName?: string\n /** Publisher logo (for the Article rich result). */\n publisherLogo?: MediaLike\n /** 'Article' (default), 'BlogPosting', or 'NewsArticle'. */\n type?: 'Article' | 'BlogPosting' | 'NewsArticle'\n}\n\n/**\n * Builds Article / BlogPosting JSON-LD (schema.org) for blog posts and articles.\n * Enables article rich results in Google (headline, date, author, image). Pairs\n * with the plugin's content collections (blog/archives).\n *\n * Per-article (each post emits its own), data from the document + panel:\n *\n * import { buildArticleJsonLd } from '@intecion/ipal-kit'\n * const jsonLd = buildArticleJsonLd({\n * headline: post.title, url: postUrl, description: post.excerpt,\n * image: post.coverImage, datePublished: post.publishedAt,\n * author: post.author, publisherName: settings.siteName,\n * publisherLogo: settings.logo, type: 'BlogPosting',\n * })\n *\n * All data from the document/panel — nothing hardcoded. Google requires headline\n * and dates for the rich result; supply them from the post's real fields.\n */\nexport function buildArticleJsonLd({\n headline,\n url,\n description,\n image,\n datePublished,\n dateModified,\n author,\n publisherName,\n publisherLogo,\n type = 'Article',\n}: ArticleJsonLdArgs) {\n const imageUrl = image?.url\n const logoUrl = publisherLogo?.url\n\n const abs = (u: string | null | undefined): string | undefined =>\n u ? (u.startsWith('http') ? u : `${url.split('/').slice(0, 3).join('/')}${u}`) : undefined\n\n return {\n '@context': 'https://schema.org',\n '@type': type,\n headline,\n url,\n mainEntityOfPage: { '@type': 'WebPage', '@id': url },\n ...(description ? { description } : {}),\n ...(imageUrl ? { image: abs(imageUrl) } : {}),\n ...(datePublished ? { datePublished } : {}),\n ...(dateModified || datePublished ? { dateModified: dateModified ?? datePublished } : {}),\n ...(author ? { author: { '@type': 'Person', name: author } } : {}),\n ...(publisherName\n ? {\n publisher: {\n '@type': 'Organization',\n name: publisherName,\n ...(logoUrl ? { logo: { '@type': 'ImageObject', url: abs(logoUrl) } } : {}),\n },\n }\n : {}),\n }\n}\n"],"names":["buildArticleJsonLd","headline","url","description","image","datePublished","dateModified","author","publisherName","publisherLogo","type","imageUrl","logoUrl","abs","u","startsWith","split","slice","join","undefined","mainEntityOfPage","name","publisher","logo"],"mappings":"AAwBA;;;;;;;;;;;;;;;;;CAiBC,GACD,OAAO,SAASA,mBAAmB,EACjCC,QAAQ,EACRC,GAAG,EACHC,WAAW,EACXC,KAAK,EACLC,aAAa,EACbC,YAAY,EACZC,MAAM,EACNC,aAAa,EACbC,aAAa,EACbC,OAAO,SAAS,EACE;IAClB,MAAMC,WAAWP,OAAOF;IACxB,MAAMU,UAAUH,eAAeP;IAE/B,MAAMW,MAAM,CAACC,IACXA,IAAKA,EAAEC,UAAU,CAAC,UAAUD,IAAI,GAAGZ,IAAIc,KAAK,CAAC,KAAKC,KAAK,CAAC,GAAG,GAAGC,IAAI,CAAC,OAAOJ,GAAG,GAAIK;IAEnF,OAAO;QACL,YAAY;QACZ,SAAST;QACTT;QACAC;QACAkB,kBAAkB;YAAE,SAAS;YAAW,OAAOlB;QAAI;QACnD,GAAIC,cAAc;YAAEA;QAAY,IAAI,CAAC,CAAC;QACtC,GAAIQ,WAAW;YAAEP,OAAOS,IAAIF;QAAU,IAAI,CAAC,CAAC;QAC5C,GAAIN,gBAAgB;YAAEA;QAAc,IAAI,CAAC,CAAC;QAC1C,GAAIC,gBAAgBD,gBAAgB;YAAEC,cAAcA,gBAAgBD;QAAc,IAAI,CAAC,CAAC;QACxF,GAAIE,SAAS;YAAEA,QAAQ;gBAAE,SAAS;gBAAUc,MAAMd;YAAO;QAAE,IAAI,CAAC,CAAC;QACjE,GAAIC,gBACA;YACEc,WAAW;gBACT,SAAS;gBACTD,MAAMb;gBACN,GAAII,UAAU;oBAAEW,MAAM;wBAAE,SAAS;wBAAerB,KAAKW,IAAID;oBAAS;gBAAE,IAAI,CAAC,CAAC;YAC5E;QACF,IACA,CAAC,CAAC;IACR;AACF"}
|
||||||
Vendored
+33
@@ -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 {};
|
||||||
Vendored
+34
@@ -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
|
||||||
+1
@@ -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"}
|
||||||
Vendored
+45
@@ -0,0 +1,45 @@
|
|||||||
|
import type { BasePayload } from 'payload';
|
||||||
|
import type { I18nConfig } from '../i18n/index.js';
|
||||||
|
type BuildLlmsTxtArgs = {
|
||||||
|
/** Absolute site URL (https://…). */
|
||||||
|
baseUrl: string;
|
||||||
|
config: I18nConfig;
|
||||||
|
/** Locale for names/descriptions. Defaults to config.defaultLocale. */
|
||||||
|
locale?: string;
|
||||||
|
/** Pages collection slug. Defaults to 'pages'. */
|
||||||
|
pagesSlug?: string;
|
||||||
|
payload: BasePayload;
|
||||||
|
/** Site settings global slug. Defaults to 'site-settings'. */
|
||||||
|
settingsSlug?: string;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Builds the body of /llms.txt — a Markdown file describing the site for AI
|
||||||
|
* agents / LLM crawlers, per the llmstxt.org convention. Mirrors buildRobots /
|
||||||
|
* buildSitemapEntries: the plugin already knows the site's name, description,
|
||||||
|
* and pages, so it can generate this automatically.
|
||||||
|
*
|
||||||
|
* Structure (llmstxt.org): H1 site name, a blockquote/summary, then a list of
|
||||||
|
* key pages as Markdown links with short descriptions. Agents read this to
|
||||||
|
* understand the site quickly without crawling everything.
|
||||||
|
*
|
||||||
|
* Wire it as a route that returns text/plain:
|
||||||
|
*
|
||||||
|
* // app/llms.txt/route.ts
|
||||||
|
* import { buildLlmsTxt } from '@intecion/ipal-kit'
|
||||||
|
* import { getCachedPayload } from '@/lib/content'
|
||||||
|
* import { i18nConfig } from '@/i18n.config'
|
||||||
|
* export const dynamic = 'force-dynamic'
|
||||||
|
* export async function GET() {
|
||||||
|
* const body = await buildLlmsTxt({
|
||||||
|
* payload: await getCachedPayload(),
|
||||||
|
* config: i18nConfig,
|
||||||
|
* baseUrl: process.env.NEXT_PUBLIC_SERVER_URL!,
|
||||||
|
* })
|
||||||
|
* return new Response(body, { headers: { 'Content-Type': 'text/plain; charset=utf-8' } })
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* Data comes from the panel (siteName, siteDescription, pages) — nothing
|
||||||
|
* hardcoded. Skips drafts and noindex pages (same as the sitemap).
|
||||||
|
*/
|
||||||
|
export declare function buildLlmsTxt({ baseUrl, config, locale, pagesSlug, payload, settingsSlug, }: BuildLlmsTxtArgs): Promise<string>;
|
||||||
|
export {};
|
||||||
Vendored
+90
@@ -0,0 +1,90 @@
|
|||||||
|
import { buildLocalizedPath } from '../i18n/index.js';
|
||||||
|
/**
|
||||||
|
* Builds the body of /llms.txt — a Markdown file describing the site for AI
|
||||||
|
* agents / LLM crawlers, per the llmstxt.org convention. Mirrors buildRobots /
|
||||||
|
* buildSitemapEntries: the plugin already knows the site's name, description,
|
||||||
|
* and pages, so it can generate this automatically.
|
||||||
|
*
|
||||||
|
* Structure (llmstxt.org): H1 site name, a blockquote/summary, then a list of
|
||||||
|
* key pages as Markdown links with short descriptions. Agents read this to
|
||||||
|
* understand the site quickly without crawling everything.
|
||||||
|
*
|
||||||
|
* Wire it as a route that returns text/plain:
|
||||||
|
*
|
||||||
|
* // app/llms.txt/route.ts
|
||||||
|
* import { buildLlmsTxt } from '@intecion/ipal-kit'
|
||||||
|
* import { getCachedPayload } from '@/lib/content'
|
||||||
|
* import { i18nConfig } from '@/i18n.config'
|
||||||
|
* export const dynamic = 'force-dynamic'
|
||||||
|
* export async function GET() {
|
||||||
|
* const body = await buildLlmsTxt({
|
||||||
|
* payload: await getCachedPayload(),
|
||||||
|
* config: i18nConfig,
|
||||||
|
* baseUrl: process.env.NEXT_PUBLIC_SERVER_URL!,
|
||||||
|
* })
|
||||||
|
* return new Response(body, { headers: { 'Content-Type': 'text/plain; charset=utf-8' } })
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* Data comes from the panel (siteName, siteDescription, pages) — nothing
|
||||||
|
* hardcoded. Skips drafts and noindex pages (same as the sitemap).
|
||||||
|
*/ export async function buildLlmsTxt({ baseUrl, config, locale, pagesSlug = 'pages', payload, settingsSlug = 'site-settings' }) {
|
||||||
|
const loc = locale ?? config.defaultLocale;
|
||||||
|
const origin = baseUrl.replace(/\/$/, '');
|
||||||
|
const settings = await payload.findGlobal({
|
||||||
|
slug: settingsSlug,
|
||||||
|
depth: 0,
|
||||||
|
locale: loc
|
||||||
|
});
|
||||||
|
const name = settings.siteName?.trim() || 'Website';
|
||||||
|
const description = settings.siteDescription?.trim();
|
||||||
|
const result = await payload.find({
|
||||||
|
collection: pagesSlug,
|
||||||
|
depth: 0,
|
||||||
|
limit: 1000,
|
||||||
|
locale: loc,
|
||||||
|
where: {
|
||||||
|
_status: {
|
||||||
|
not_equals: 'draft'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const lines = [
|
||||||
|
`# ${name}`,
|
||||||
|
''
|
||||||
|
];
|
||||||
|
if (description) {
|
||||||
|
lines.push(`> ${description}`, '');
|
||||||
|
}
|
||||||
|
const pageLinks = [];
|
||||||
|
for (const raw of result.docs){
|
||||||
|
if (raw._status && raw._status !== 'published') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (raw.meta?.noindex) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const title = typeof raw.title === 'string' ? raw.title : undefined;
|
||||||
|
const slug = typeof raw.slug === 'string' ? raw.slug : undefined;
|
||||||
|
if (!title || !slug) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const path = buildLocalizedPath({
|
||||||
|
config,
|
||||||
|
locale: loc,
|
||||||
|
slugs: {
|
||||||
|
[loc]: slug
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!path) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const pageDesc = typeof raw.meta?.description === 'string' ? ` — ${raw.meta.description}` : '';
|
||||||
|
pageLinks.push(`- [${title}](${origin}${path})${pageDesc}`);
|
||||||
|
}
|
||||||
|
if (pageLinks.length > 0) {
|
||||||
|
lines.push('## Strony', '', ...pageLinks, '');
|
||||||
|
}
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
//# sourceMappingURL=buildLlmsTxt.js.map
|
||||||
Vendored
+1
File diff suppressed because one or more lines are too long
+71
@@ -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 {};
|
||||||
+73
@@ -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
|
||||||
@@ -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"}
|
||||||
Vendored
+20
-2
@@ -19,6 +19,11 @@ export type PageMetadata = {
|
|||||||
locale?: string;
|
locale?: string;
|
||||||
title: string;
|
title: string;
|
||||||
};
|
};
|
||||||
|
/** robots directives — set to noindex/follow for legal/thin/search pages. */
|
||||||
|
robots?: {
|
||||||
|
follow: boolean;
|
||||||
|
index: boolean;
|
||||||
|
};
|
||||||
title: string;
|
title: string;
|
||||||
};
|
};
|
||||||
type BuildMetadataArgs = {
|
type BuildMetadataArgs = {
|
||||||
@@ -26,7 +31,7 @@ type BuildMetadataArgs = {
|
|||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
config: I18nConfig;
|
config: I18nConfig;
|
||||||
/** Home slug that collapses to the locale root. Defaults to 'home'. */
|
/** Home slug that collapses to the locale root. Defaults to 'home'. */
|
||||||
homeSlug?: string;
|
homeSlug?: Record<string, string> | string;
|
||||||
/** Resolved OG image URL (page image or site defaultShareImage). */
|
/** Resolved OG image URL (page image or site defaultShareImage). */
|
||||||
imageUrl?: null | string;
|
imageUrl?: null | string;
|
||||||
/** Current locale being rendered. */
|
/** Current locale being rendered. */
|
||||||
@@ -35,6 +40,13 @@ type BuildMetadataArgs = {
|
|||||||
meta?: null | SeoMeta;
|
meta?: null | SeoMeta;
|
||||||
/** Page title or site name first. Defaults to 'page-first'. */
|
/** Page title or site name first. Defaults to 'page-first'. */
|
||||||
order?: TitleOrder;
|
order?: TitleOrder;
|
||||||
|
/**
|
||||||
|
* The document's own title (e.g. page.title = 'Sprzątanie biur'). Used as the
|
||||||
|
* page-title source when meta.title is empty — the browser tab and search
|
||||||
|
* result should show the page name, not go blank, when an editor didn't fill
|
||||||
|
* the SEO title. Priority: titleOverride > meta.title > pageTitle.
|
||||||
|
*/
|
||||||
|
pageTitle?: null | string;
|
||||||
/**
|
/**
|
||||||
* Localized segment the document lives under (an archive page's slugs).
|
* Localized segment the document lives under (an archive page's slugs).
|
||||||
* Feeds both canonical and hreflang, so /pl/artykuly/moj-post and
|
* Feeds both canonical and hreflang, so /pl/artykuly/moj-post and
|
||||||
@@ -51,6 +63,12 @@ type BuildMetadataArgs = {
|
|||||||
query?: string;
|
query?: string;
|
||||||
/** Separator between page title and site name. Defaults to ' | '. */
|
/** Separator between page title and site name. Defaults to ' | '. */
|
||||||
separator?: string;
|
separator?: string;
|
||||||
|
/**
|
||||||
|
* Global fallback description (from SiteSettings.siteDescription). Used when a
|
||||||
|
* page has no meta.description — better than emitting no description at all
|
||||||
|
* (Google would then scrape random on-page text for the snippet).
|
||||||
|
*/
|
||||||
|
siteDescription?: null | string;
|
||||||
/** Site name for title composition and OG. */
|
/** Site name for title composition and OG. */
|
||||||
siteName?: null | string;
|
siteName?: null | string;
|
||||||
/** slug per locale for this document — drives canonical + hreflang. */
|
/** slug per locale for this document — drives canonical + hreflang. */
|
||||||
@@ -65,5 +83,5 @@ type BuildMetadataArgs = {
|
|||||||
* pieces (meta group, site name, image URL, localized slugs) and passes them
|
* pieces (meta group, site name, image URL, localized slugs) and passes them
|
||||||
* in — the plugin composes, it doesn't fetch.
|
* in — the plugin composes, it doesn't fetch.
|
||||||
*/
|
*/
|
||||||
export declare function buildMetadata({ baseUrl, config, homeSlug, imageUrl, locale, meta, order, prefix, query, separator, siteName, slugs, }: BuildMetadataArgs): PageMetadata;
|
export declare function buildMetadata({ baseUrl, config, homeSlug, imageUrl, locale, meta, order, pageTitle, prefix, query, separator, siteDescription, siteName, slugs, }: BuildMetadataArgs): PageMetadata;
|
||||||
export {};
|
export {};
|
||||||
|
|||||||
Vendored
+17
-6
@@ -9,17 +9,20 @@ import { buildHreflangAlternates } from './hreflang.js';
|
|||||||
* Designed for use inside Next.js `generateMetadata`. The caller resolves the
|
* Designed for use inside Next.js `generateMetadata`. The caller resolves the
|
||||||
* pieces (meta group, site name, image URL, localized slugs) and passes them
|
* pieces (meta group, site name, image URL, localized slugs) and passes them
|
||||||
* in — the plugin composes, it doesn't fetch.
|
* in — the plugin composes, it doesn't fetch.
|
||||||
*/ export function buildMetadata({ baseUrl, config, homeSlug = 'home', imageUrl, locale, meta, order, prefix, query, separator, siteName, slugs }) {
|
*/ export function buildMetadata({ baseUrl, config, homeSlug = 'home', imageUrl, locale, meta, order, pageTitle, prefix, query, separator, siteDescription, siteName, slugs }) {
|
||||||
// titleOverride wins outright: an editor who filled it in wants that exact
|
// Title source priority: titleOverride (exact, wins outright) > meta.title
|
||||||
// string in the tab, not a composition.
|
// (SEO title an editor set) > pageTitle (the document's own name). This means
|
||||||
|
// a page with no SEO title still shows its name (e.g. 'Sprzątanie biur')
|
||||||
|
// composed with the site name, instead of just the site name or a blank.
|
||||||
const override = meta?.titleOverride?.trim();
|
const override = meta?.titleOverride?.trim();
|
||||||
|
const resolvedPageTitle = meta?.title?.trim() || pageTitle?.trim() || undefined;
|
||||||
const title = override || composeTitle({
|
const title = override || composeTitle({
|
||||||
order,
|
order,
|
||||||
pageTitle: meta?.title,
|
pageTitle: resolvedPageTitle,
|
||||||
separator,
|
separator,
|
||||||
siteName
|
siteName
|
||||||
});
|
});
|
||||||
const description = meta?.description?.trim() || undefined;
|
const description = meta?.description?.trim() || siteDescription?.trim() || undefined;
|
||||||
const origin = baseUrl?.replace(/\/$/, '') ?? '';
|
const origin = baseUrl?.replace(/\/$/, '') ?? '';
|
||||||
const suffix = query ?? '';
|
const suffix = query ?? '';
|
||||||
const currentPath = buildLocalizedPath({
|
const currentPath = buildLocalizedPath({
|
||||||
@@ -69,7 +72,15 @@ import { buildHreflangAlternates } from './hreflang.js';
|
|||||||
images
|
images
|
||||||
},
|
},
|
||||||
locale
|
locale
|
||||||
}
|
},
|
||||||
|
// noindex → tell search engines to exclude the page but still follow links
|
||||||
|
// (authority flows through). For legal/thin/search-result pages.
|
||||||
|
...meta?.noindex ? {
|
||||||
|
robots: {
|
||||||
|
follow: true,
|
||||||
|
index: false
|
||||||
|
}
|
||||||
|
} : {}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
+39
@@ -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 {};
|
||||||
+35
@@ -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
|
||||||
+1
@@ -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"}
|
||||||
+1
-1
@@ -29,7 +29,7 @@ type BuildSitemapArgs = {
|
|||||||
* Slug of the page that is the site root (collapses to /{locale}).
|
* Slug of the page that is the site root (collapses to /{locale}).
|
||||||
* Read from System Pages when omitted.
|
* Read from System Pages when omitted.
|
||||||
*/
|
*/
|
||||||
homeSlug?: string;
|
homeSlug?: Record<string, string> | string;
|
||||||
/** Pages collection slug. Defaults to 'pages'. */
|
/** Pages collection slug. Defaults to 'pages'. */
|
||||||
pagesSlug?: string;
|
pagesSlug?: string;
|
||||||
payload: BasePayload;
|
payload: BasePayload;
|
||||||
|
|||||||
+46
-7
@@ -1,13 +1,40 @@
|
|||||||
import { archiveFieldName } from '../content/index.js';
|
import { archiveFieldName } from '../content/index.js';
|
||||||
import { buildLocalizedPath, getLocalizedSlugs } from '../i18n/index.js';
|
import { buildLocalizedPath, getLocaleCodes, getLocalizedSlugs } from '../i18n/index.js';
|
||||||
import { buildHreflangAlternates } from './hreflang.js';
|
import { buildHreflangAlternates } from './hreflang.js';
|
||||||
/** Skip drafts and anything flagged noindex in the SEO tab. */ function isIndexable(doc) {
|
/**
|
||||||
|
* Slugs that must never appear in the sitemap — error/system pages that exist as
|
||||||
|
* documents (e.g. a '404' page in the Pages collection) but should not be
|
||||||
|
* indexed. A sitemap should list only real, HTTP-200 content; a '/pl/404' entry
|
||||||
|
* is an audit finding. Matched against the slug in any locale.
|
||||||
|
*/ const EXCLUDED_SITEMAP_SLUGS = new Set([
|
||||||
|
'404',
|
||||||
|
'500',
|
||||||
|
'error',
|
||||||
|
'not-found'
|
||||||
|
]);
|
||||||
|
/** True if the doc's slug (in any locale) is an excluded system/error slug. */ function hasExcludedSlug(slug) {
|
||||||
|
if (typeof slug === 'string') {
|
||||||
|
return EXCLUDED_SITEMAP_SLUGS.has(slug);
|
||||||
|
}
|
||||||
|
if (slug && typeof slug === 'object') {
|
||||||
|
for (const value of Object.values(slug)){
|
||||||
|
if (typeof value === 'string' && EXCLUDED_SITEMAP_SLUGS.has(value)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
/** Skip drafts, noindex, and system/error pages (404 etc.). */ function isIndexable(doc) {
|
||||||
if (doc._status && doc._status !== 'published') {
|
if (doc._status && doc._status !== 'published') {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (doc.meta?.noindex) {
|
if (doc.meta?.noindex) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (hasExcludedSlug(doc.slug)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
@@ -83,7 +110,13 @@ import { buildHreflangAlternates } from './hreflang.js';
|
|||||||
depth: 1,
|
depth: 1,
|
||||||
locale: 'all'
|
locale: 'all'
|
||||||
});
|
});
|
||||||
const resolvedHomeSlug = homeSlug ?? extractSlugInLocale(settings.homepage, defaultLocale) ?? 'home';
|
// Home slug as a per-locale MAP so every language's homepage collapses to its
|
||||||
|
// root in the sitemap (/pl, /de, /en) — not just the default locale. Priority:
|
||||||
|
// caller-provided homeSlug > full map from the homepage relationship > single
|
||||||
|
// default-locale slug > 'home'. slugMapAllLocales already builds the map (it's
|
||||||
|
// used for archive prefixes below); reusing it here fixes /de/startseite
|
||||||
|
// appearing in the sitemap instead of /de.
|
||||||
|
const resolvedHomeSlug = homeSlug ?? slugMapAllLocales(settings.homepage, locales) ?? extractSlugInLocale(settings.homepage, defaultLocale) ?? 'home';
|
||||||
// Which collections to walk: pages (no prefix) + each content collection with
|
// Which collections to walk: pages (no prefix) + each content collection with
|
||||||
// its archive-page slugs as the localized prefix.
|
// its archive-page slugs as the localized prefix.
|
||||||
const collections = [
|
const collections = [
|
||||||
@@ -114,10 +147,16 @@ import { buildHreflangAlternates } from './hreflang.js';
|
|||||||
if (!isIndexable(raw)) {
|
if (!isIndexable(raw)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Emit the entry under the default locale's URL; alternates cover the rest.
|
// Emit a separate <url> for EACH locale. Google's sitemap spec for
|
||||||
const entry = entryFor(raw, defaultLocale, config, baseUrl, resolvedHomeSlug, collection.prefixSlugs, changeFrequency);
|
// localized sites requires one <url> per language version (each with its
|
||||||
if (entry) {
|
// own <loc> plus xhtml:link alternates), not a single default-locale <url>
|
||||||
entries.push(entry);
|
// with the others hidden only in alternates. Emitting only the default
|
||||||
|
// locale makes GSC count 8 URLs instead of 8×3 — a real reporting gap.
|
||||||
|
for (const locale of getLocaleCodes(config)){
|
||||||
|
const entry = entryFor(raw, locale, config, baseUrl, resolvedHomeSlug, collection.prefixSlugs, changeFrequency);
|
||||||
|
if (entry) {
|
||||||
|
entries.push(entry);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+6
-6
@@ -1,14 +1,14 @@
|
|||||||
/** Which part comes first in a composed title. */
|
/** Which part comes first in a composed title. */
|
||||||
export type TitleOrder = 'page-first' | 'site-first';
|
export type TitleOrder = 'page-first' | 'site-first';
|
||||||
type ComposeTitleArgs = {
|
type ComposeTitleArgs = {
|
||||||
/** Defaults to 'page-first' — the page title is what a visitor scans for. */
|
|
||||||
order?: TitleOrder;
|
|
||||||
/** Page-specific title, e.g. 'About Us'. */
|
/** Page-specific title, e.g. 'About Us'. */
|
||||||
pageTitle?: null | string;
|
pageTitle?: string | null;
|
||||||
|
/** Site name, e.g. 'Acme Inc'. */
|
||||||
|
siteName?: string | null;
|
||||||
/** Separator between page title and site name. Defaults to ' | '. */
|
/** Separator between page title and site name. Defaults to ' | '. */
|
||||||
separator?: string;
|
separator?: string;
|
||||||
/** Site name, e.g. 'Acme Inc'. */
|
/** Defaults to 'page-first' — the page title is what a visitor scans for. */
|
||||||
siteName?: null | string;
|
order?: TitleOrder;
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* Composes a full document title from a page title and the site name.
|
* Composes a full document title from a page title and the site name.
|
||||||
@@ -21,5 +21,5 @@ type ComposeTitleArgs = {
|
|||||||
*
|
*
|
||||||
* Pure function — no dependency on Payload or request state.
|
* Pure function — no dependency on Payload or request state.
|
||||||
*/
|
*/
|
||||||
export declare function composeTitle({ order, pageTitle, separator, siteName, }: ComposeTitleArgs): string;
|
export declare function composeTitle({ pageTitle, siteName, separator, order, }: ComposeTitleArgs): string;
|
||||||
export {};
|
export {};
|
||||||
|
|||||||
Vendored
+9
-1
@@ -8,10 +8,18 @@
|
|||||||
* - neither: ""
|
* - neither: ""
|
||||||
*
|
*
|
||||||
* Pure function — no dependency on Payload or request state.
|
* Pure function — no dependency on Payload or request state.
|
||||||
*/ export function composeTitle({ order = 'page-first', pageTitle, separator = ' | ', siteName }) {
|
*/ export function composeTitle({ pageTitle, siteName, separator = ' | ', order = 'page-first' }) {
|
||||||
const page = pageTitle?.trim();
|
const page = pageTitle?.trim();
|
||||||
const site = siteName?.trim();
|
const site = siteName?.trim();
|
||||||
if (page && site) {
|
if (page && site) {
|
||||||
|
// Dedup: if the page title already ends with the site name (an editor wrote
|
||||||
|
// "Budowa auta – RCC" into meta.title), don't append it again → avoid
|
||||||
|
// "Budowa auta – RCC – RCC". Match the site name at the end, with or without
|
||||||
|
// the separator before it.
|
||||||
|
const sep = separator.trim();
|
||||||
|
if (page === site || page.endsWith(`${sep} ${site}`) || page.endsWith(` ${site}`)) {
|
||||||
|
return page;
|
||||||
|
}
|
||||||
return order === 'site-first' ? `${site}${separator}${page}` : `${page}${separator}${site}`;
|
return order === 'site-first' ? `${site}${separator}${page}` : `${page}${separator}${site}`;
|
||||||
}
|
}
|
||||||
return page || site || '';
|
return page || site || '';
|
||||||
|
|||||||
Vendored
+1
-1
@@ -1 +1 @@
|
|||||||
{"version":3,"sources":["../../../src/modules/seo/composeTitle.ts"],"sourcesContent":["/** Which part comes first in a composed title. */\nexport type TitleOrder = 'page-first' | 'site-first'\n\ntype ComposeTitleArgs = {\n /** Defaults to 'page-first' — the page title is what a visitor scans for. */\n order?: TitleOrder\n /** Page-specific title, e.g. 'About Us'. */\n pageTitle?: null | string\n /** Separator between page title and site name. Defaults to ' | '. */\n separator?: string\n /** Site name, e.g. 'Acme Inc'. */\n siteName?: null | string\n}\n\n/**\n * Composes a full document title from a page title and the site name.\n *\n * - both, page-first: \"About Us | Acme Inc\"\n * - both, site-first: \"Acme Inc | About Us\"\n * - page only: \"About Us\"\n * - site only: \"Acme Inc\"\n * - neither: \"\"\n *\n * Pure function — no dependency on Payload or request state.\n */\nexport function composeTitle({\n order = 'page-first',\n pageTitle,\n separator = ' | ',\n siteName,\n}: ComposeTitleArgs): string {\n const page = pageTitle?.trim()\n const site = siteName?.trim()\n\n if (page && site) {\n return order === 'site-first' ? `${site}${separator}${page}` : `${page}${separator}${site}`\n }\n return page || site || ''\n}\n"],"names":["composeTitle","order","pageTitle","separator","siteName","page","trim","site"],"mappings":"AAAA,gDAAgD,GAchD;;;;;;;;;;CAUC,GACD,OAAO,SAASA,aAAa,EAC3BC,QAAQ,YAAY,EACpBC,SAAS,EACTC,YAAY,KAAK,EACjBC,QAAQ,EACS;IACjB,MAAMC,OAAOH,WAAWI;IACxB,MAAMC,OAAOH,UAAUE;IAEvB,IAAID,QAAQE,MAAM;QAChB,OAAON,UAAU,eAAe,GAAGM,OAAOJ,YAAYE,MAAM,GAAG,GAAGA,OAAOF,YAAYI,MAAM;IAC7F;IACA,OAAOF,QAAQE,QAAQ;AACzB"}
|
{"version":3,"sources":["../../../src/modules/seo/composeTitle.ts"],"sourcesContent":["/** Which part comes first in a composed title. */\nexport type TitleOrder = 'page-first' | 'site-first'\n\ntype ComposeTitleArgs = {\n /** Page-specific title, e.g. 'About Us'. */\n pageTitle?: string | null\n /** Site name, e.g. 'Acme Inc'. */\n siteName?: string | null\n /** Separator between page title and site name. Defaults to ' | '. */\n separator?: string\n /** Defaults to 'page-first' — the page title is what a visitor scans for. */\n order?: TitleOrder\n}\n\n/**\n * Composes a full document title from a page title and the site name.\n *\n * - both, page-first: \"About Us | Acme Inc\"\n * - both, site-first: \"Acme Inc | About Us\"\n * - page only: \"About Us\"\n * - site only: \"Acme Inc\"\n * - neither: \"\"\n *\n * Pure function — no dependency on Payload or request state.\n */\nexport function composeTitle({\n pageTitle,\n siteName,\n separator = ' | ',\n order = 'page-first',\n}: ComposeTitleArgs): string {\n const page = pageTitle?.trim()\n const site = siteName?.trim()\n\n if (page && site) {\n // Dedup: if the page title already ends with the site name (an editor wrote\n // \"Budowa auta – RCC\" into meta.title), don't append it again → avoid\n // \"Budowa auta – RCC – RCC\". Match the site name at the end, with or without\n // the separator before it.\n const sep = separator.trim()\n if (page === site || page.endsWith(`${sep} ${site}`) || page.endsWith(` ${site}`)) {\n return page\n }\n return order === 'site-first' ? `${site}${separator}${page}` : `${page}${separator}${site}`\n }\n return page || site || ''\n}\n"],"names":["composeTitle","pageTitle","siteName","separator","order","page","trim","site","sep","endsWith"],"mappings":"AAAA,gDAAgD,GAchD;;;;;;;;;;CAUC,GACD,OAAO,SAASA,aAAa,EAC3BC,SAAS,EACTC,QAAQ,EACRC,YAAY,KAAK,EACjBC,QAAQ,YAAY,EACH;IACjB,MAAMC,OAAOJ,WAAWK;IACxB,MAAMC,OAAOL,UAAUI;IAEvB,IAAID,QAAQE,MAAM;QAChB,4EAA4E;QAC5E,sEAAsE;QACtE,6EAA6E;QAC7E,2BAA2B;QAC3B,MAAMC,MAAML,UAAUG,IAAI;QAC1B,IAAID,SAASE,QAAQF,KAAKI,QAAQ,CAAC,GAAGD,IAAI,CAAC,EAAED,MAAM,KAAKF,KAAKI,QAAQ,CAAC,CAAC,CAAC,EAAEF,MAAM,GAAG;YACjF,OAAOF;QACT;QACA,OAAOD,UAAU,eAAe,GAAGG,OAAOJ,YAAYE,MAAM,GAAG,GAAGA,OAAOF,YAAYI,MAAM;IAC7F;IACA,OAAOF,QAAQE,QAAQ;AACzB"}
|
||||||
+1
-1
@@ -14,7 +14,7 @@ type CreateMetadataGeneratorArgs = {
|
|||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
config: I18nConfig;
|
config: I18nConfig;
|
||||||
/** Home slug that collapses to the locale root. Defaults to 'home'. */
|
/** Home slug that collapses to the locale root. Defaults to 'home'. */
|
||||||
homeSlug?: string;
|
homeSlug?: string | Record<string, string>;
|
||||||
/**
|
/**
|
||||||
* Resolves the document to build metadata for, given route params.
|
* Resolves the document to build metadata for, given route params.
|
||||||
* The client supplies this (they own the collections and routing); it should
|
* The client supplies this (they own the collections and routing); it should
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
{"version":3,"sources":["../../../src/modules/seo/createMetadataGenerator.ts"],"sourcesContent":["import type { BasePayload } from 'payload'\n\nimport type { I18nConfig, LocalizedSlugs } from '../i18n/index.js'\nimport type { PageMetadata } from './buildMetadata.js'\nimport type { SeoMeta } from './types.js'\n\nimport { getLocalizedSlugs } from '../i18n/index.js'\nimport { buildMetadata } from './buildMetadata.js'\n\n/**\n * A document as needed for metadata: its SEO meta group and localized slug.\n */\ntype MetadataDocument = {\n meta?: null | SeoMeta\n slug?: null | Record<string, unknown>\n}\n\ntype CreateMetadataGeneratorArgs = {\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 /**\n * Resolves the document to build metadata for, given route params.\n * The client supplies this (they own the collections and routing); it should\n * fetch with `locale: 'all'` so the slug field is a locale→value map.\n */\n resolveDocument: (args: {\n locale: string\n params: Record<string, string | string[]>\n payload: BasePayload\n }) => Promise<MetadataDocument | null>\n /** Resolves the OG image URL for the document, if any. */\n resolveImageUrl?: (args: {\n doc: MetadataDocument\n locale: string\n payload: BasePayload\n }) => Promise<null | string>\n /** Resolves the site name (e.g. from SiteSettings). */\n resolveSiteName?: (args: { locale: string; payload: BasePayload }) => Promise<null | string>\n}\n\n/**\n * Builds a metadata generator, collapsing the usual generateMetadata\n * boilerplate into a single wired-up function.\n *\n * The client provides resolvers (they own collections/routing); the plugin\n * owns the assembly (title composition, canonical, hreflang, OG).\n *\n * The returned function takes `{ payload, params, locale }` — Next calls\n * `generateMetadata({ params })` without those, and the plugin never calls\n * getPayload itself, so the client wraps it in their route file:\n *\n * ```ts\n * // app/(frontend)/[locale]/[[...slug]]/page.tsx\n * const generate = createMetadataGenerator({\n * config: i18nConfig,\n * baseUrl: process.env.NEXT_PUBLIC_SERVER_URL,\n * resolveDocument: async ({ payload, params, locale }) => { ... },\n * })\n *\n * export async function generateMetadata({ params }) {\n * const { locale, slug } = await params\n * const payload = await getPayload({ config: await config })\n * return generate({ payload, params: { slug: slug ?? [] }, locale })\n * }\n * ```\n *\n * `resolveDocument` should fetch with `locale: 'all'` so the slug field comes\n * back as a locale→value map — that's what hreflang alternates are built from.\n */\nexport function createMetadataGenerator(args: CreateMetadataGeneratorArgs) {\n const { baseUrl, config, homeSlug, resolveDocument, resolveImageUrl, resolveSiteName } = args\n\n return async function generateMetadata(context: {\n locale: string\n params: Record<string, string | string[]>\n payload: BasePayload\n }): Promise<PageMetadata> {\n const { locale, params, payload } = context\n\n const doc = await resolveDocument({ locale, params, payload })\n\n const slugs: LocalizedSlugs = doc?.slug\n ? getLocalizedSlugs({ config, slugField: doc.slug })\n : {}\n\n const [siteName, imageUrl] = await Promise.all([\n resolveSiteName?.({ locale, payload }) ?? Promise.resolve(null),\n doc && resolveImageUrl ? resolveImageUrl({ doc, locale, payload }) : Promise.resolve(null),\n ])\n\n return buildMetadata({\n baseUrl,\n config,\n homeSlug,\n imageUrl,\n locale,\n meta: doc?.meta,\n siteName,\n slugs,\n })\n }\n}\n"],"names":["getLocalizedSlugs","buildMetadata","createMetadataGenerator","args","baseUrl","config","homeSlug","resolveDocument","resolveImageUrl","resolveSiteName","generateMetadata","context","locale","params","payload","doc","slugs","slug","slugField","siteName","imageUrl","Promise","all","resolve","meta"],"mappings":"AAMA,SAASA,iBAAiB,QAAQ,mBAAkB;AACpD,SAASC,aAAa,QAAQ,qBAAoB;AAoClD;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA4BC,GACD,OAAO,SAASC,wBAAwBC,IAAiC;IACvE,MAAM,EAAEC,OAAO,EAAEC,MAAM,EAAEC,QAAQ,EAAEC,eAAe,EAAEC,eAAe,EAAEC,eAAe,EAAE,GAAGN;IAEzF,OAAO,eAAeO,iBAAiBC,OAItC;QACC,MAAM,EAAEC,MAAM,EAAEC,MAAM,EAAEC,OAAO,EAAE,GAAGH;QAEpC,MAAMI,MAAM,MAAMR,gBAAgB;YAAEK;YAAQC;YAAQC;QAAQ;QAE5D,MAAME,QAAwBD,KAAKE,OAC/BjB,kBAAkB;YAAEK;YAAQa,WAAWH,IAAIE,IAAI;QAAC,KAChD,CAAC;QAEL,MAAM,CAACE,UAAUC,SAAS,GAAG,MAAMC,QAAQC,GAAG,CAAC;YAC7Cb,kBAAkB;gBAAEG;gBAAQE;YAAQ,MAAMO,QAAQE,OAAO,CAAC;YAC1DR,OAAOP,kBAAkBA,gBAAgB;gBAAEO;gBAAKH;gBAAQE;YAAQ,KAAKO,QAAQE,OAAO,CAAC;SACtF;QAED,OAAOtB,cAAc;YACnBG;YACAC;YACAC;YACAc;YACAR;YACAY,MAAMT,KAAKS;YACXL;YACAH;QACF;IACF;AACF"}
|
{"version":3,"sources":["../../../src/modules/seo/createMetadataGenerator.ts"],"sourcesContent":["import type { BasePayload } from 'payload'\n\nimport type { I18nConfig, LocalizedSlugs } from '../i18n/index.js'\nimport type { PageMetadata } from './buildMetadata.js'\nimport type { SeoMeta } from './types.js'\n\nimport { getLocalizedSlugs } from '../i18n/index.js'\nimport { buildMetadata } from './buildMetadata.js'\n\n/**\n * A document as needed for metadata: its SEO meta group and localized slug.\n */\ntype MetadataDocument = {\n meta?: null | SeoMeta\n slug?: null | Record<string, unknown>\n}\n\ntype CreateMetadataGeneratorArgs = {\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 | Record<string, string>\n /**\n * Resolves the document to build metadata for, given route params.\n * The client supplies this (they own the collections and routing); it should\n * fetch with `locale: 'all'` so the slug field is a locale→value map.\n */\n resolveDocument: (args: {\n locale: string\n params: Record<string, string | string[]>\n payload: BasePayload\n }) => Promise<MetadataDocument | null>\n /** Resolves the OG image URL for the document, if any. */\n resolveImageUrl?: (args: {\n doc: MetadataDocument\n locale: string\n payload: BasePayload\n }) => Promise<null | string>\n /** Resolves the site name (e.g. from SiteSettings). */\n resolveSiteName?: (args: { locale: string; payload: BasePayload }) => Promise<null | string>\n}\n\n/**\n * Builds a metadata generator, collapsing the usual generateMetadata\n * boilerplate into a single wired-up function.\n *\n * The client provides resolvers (they own collections/routing); the plugin\n * owns the assembly (title composition, canonical, hreflang, OG).\n *\n * The returned function takes `{ payload, params, locale }` — Next calls\n * `generateMetadata({ params })` without those, and the plugin never calls\n * getPayload itself, so the client wraps it in their route file:\n *\n * ```ts\n * // app/(frontend)/[locale]/[[...slug]]/page.tsx\n * const generate = createMetadataGenerator({\n * config: i18nConfig,\n * baseUrl: process.env.NEXT_PUBLIC_SERVER_URL,\n * resolveDocument: async ({ payload, params, locale }) => { ... },\n * })\n *\n * export async function generateMetadata({ params }) {\n * const { locale, slug } = await params\n * const payload = await getPayload({ config: await config })\n * return generate({ payload, params: { slug: slug ?? [] }, locale })\n * }\n * ```\n *\n * `resolveDocument` should fetch with `locale: 'all'` so the slug field comes\n * back as a locale→value map — that's what hreflang alternates are built from.\n */\nexport function createMetadataGenerator(args: CreateMetadataGeneratorArgs) {\n const { baseUrl, config, homeSlug, resolveDocument, resolveImageUrl, resolveSiteName } = args\n\n return async function generateMetadata(context: {\n locale: string\n params: Record<string, string | string[]>\n payload: BasePayload\n }): Promise<PageMetadata> {\n const { locale, params, payload } = context\n\n const doc = await resolveDocument({ locale, params, payload })\n\n const slugs: LocalizedSlugs = doc?.slug\n ? getLocalizedSlugs({ config, slugField: doc.slug })\n : {}\n\n const [siteName, imageUrl] = await Promise.all([\n resolveSiteName?.({ locale, payload }) ?? Promise.resolve(null),\n doc && resolveImageUrl ? resolveImageUrl({ doc, locale, payload }) : Promise.resolve(null),\n ])\n\n return buildMetadata({\n baseUrl,\n config,\n homeSlug,\n imageUrl,\n locale,\n meta: doc?.meta,\n siteName,\n slugs,\n })\n }\n}\n"],"names":["getLocalizedSlugs","buildMetadata","createMetadataGenerator","args","baseUrl","config","homeSlug","resolveDocument","resolveImageUrl","resolveSiteName","generateMetadata","context","locale","params","payload","doc","slugs","slug","slugField","siteName","imageUrl","Promise","all","resolve","meta"],"mappings":"AAMA,SAASA,iBAAiB,QAAQ,mBAAkB;AACpD,SAASC,aAAa,QAAQ,qBAAoB;AAoClD;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA4BC,GACD,OAAO,SAASC,wBAAwBC,IAAiC;IACvE,MAAM,EAAEC,OAAO,EAAEC,MAAM,EAAEC,QAAQ,EAAEC,eAAe,EAAEC,eAAe,EAAEC,eAAe,EAAE,GAAGN;IAEzF,OAAO,eAAeO,iBAAiBC,OAItC;QACC,MAAM,EAAEC,MAAM,EAAEC,MAAM,EAAEC,OAAO,EAAE,GAAGH;QAEpC,MAAMI,MAAM,MAAMR,gBAAgB;YAAEK;YAAQC;YAAQC;QAAQ;QAE5D,MAAME,QAAwBD,KAAKE,OAC/BjB,kBAAkB;YAAEK;YAAQa,WAAWH,IAAIE,IAAI;QAAC,KAChD,CAAC;QAEL,MAAM,CAACE,UAAUC,SAAS,GAAG,MAAMC,QAAQC,GAAG,CAAC;YAC7Cb,kBAAkB;gBAAEG;gBAAQE;YAAQ,MAAMO,QAAQE,OAAO,CAAC;YAC1DR,OAAOP,kBAAkBA,gBAAgB;gBAAEO;gBAAKH;gBAAQE;YAAQ,KAAKO,QAAQE,OAAO,CAAC;SACtF;QAED,OAAOtB,cAAc;YACnBG;YACAC;YACAC;YACAc;YACAR;YACAY,MAAMT,KAAKS;YACXL;YACAH;QACF;IACF;AACF"}
|
||||||
+1
@@ -90,6 +90,7 @@ import { slugsAcrossLocales } from './slugsAcrossLocales.js';
|
|||||||
...base,
|
...base,
|
||||||
imageUrl: resolveOgImage(doc),
|
imageUrl: resolveOgImage(doc),
|
||||||
meta: doc.meta,
|
meta: doc.meta,
|
||||||
|
pageTitle: doc.title,
|
||||||
prefix,
|
prefix,
|
||||||
query,
|
query,
|
||||||
slugs
|
slugs
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+7
-15
@@ -1,42 +1,34 @@
|
|||||||
import type { I18nConfig, LocalizedSlugs } from '../i18n/index.js';
|
import type { I18nConfig, LocalizedSlugs } from '../i18n/index.js';
|
||||||
type BuildHreflangArgs = {
|
type BuildHreflangArgs = {
|
||||||
|
/** slug per locale for the current document, e.g. { pl: 'o-nas', en: 'about' } */
|
||||||
|
slugs: LocalizedSlugs;
|
||||||
|
config: I18nConfig;
|
||||||
/** Absolute site origin, e.g. 'https://example.com'. Omit for relative paths. */
|
/** Absolute site origin, e.g. 'https://example.com'. Omit for relative paths. */
|
||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
config: I18nConfig;
|
|
||||||
/** Home slug that collapses to the locale root. Defaults to 'home'. */
|
/** Home slug that collapses to the locale root. Defaults to 'home'. */
|
||||||
homeSlug?: string;
|
homeSlug?: string | Record<string, string>;
|
||||||
/**
|
/**
|
||||||
* Localized segment the document lives under (an archive page's slugs),
|
* Localized segment the document lives under (an archive page's slugs),
|
||||||
* e.g. { pl: 'artykuly', en: 'articles' }. Locales missing from the prefix
|
* e.g. { pl: 'artykuly', en: 'articles' }. Locales missing from the prefix
|
||||||
* are omitted — an entry with no archive in that language has no URL there.
|
* are omitted — an entry with no archive in that language has no URL there.
|
||||||
*/
|
*/
|
||||||
prefix?: LocalizedSlugs;
|
prefix?: LocalizedSlugs;
|
||||||
/** slug per locale for the current document, e.g. { pl: 'o-nas', en: 'about' } */
|
|
||||||
slugs: LocalizedSlugs;
|
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
|
* Builds a map of locale → URL for hreflang alternate links, suitable for
|
||||||
* Next.js Metadata `alternates.languages`.
|
* Next.js Metadata `alternates.languages`.
|
||||||
*
|
*
|
||||||
* Bridges SEO and i18n: for each configured locale that the document has a
|
* Bridges SEO and i18n: for each configured locale that the document has a
|
||||||
* slug in, it produces the locale-aware path (via buildLocalizedPath),
|
* slug in, it produces the locale-aware path (via buildLocalizedPath),
|
||||||
* optionally prefixed with an absolute origin.
|
* optionally prefixed with an absolute origin.
|
||||||
*
|
*
|
||||||
* Also emits `x-default` pointing at the default locale — the version Google
|
|
||||||
* serves when the user's language/region matches no hreflang, and the fallback
|
|
||||||
* when the root ('/') redirect is ambiguous (Googlebot with no/foreign
|
|
||||||
* Accept-Language).
|
|
||||||
*
|
|
||||||
* @example
|
* @example
|
||||||
* buildHreflangAlternates({
|
* buildHreflangAlternates({
|
||||||
* slugs: { pl: 'o-nas', en: 'about' },
|
* slugs: { pl: 'o-nas', en: 'about' },
|
||||||
* config,
|
* config,
|
||||||
* baseUrl: 'https://example.com',
|
* baseUrl: 'https://example.com',
|
||||||
* })
|
* })
|
||||||
* // → {
|
* // → { pl: 'https://example.com/pl/o-nas', en: 'https://example.com/en/about' }
|
||||||
* // pl: 'https://example.com/pl/o-nas',
|
|
||||||
* // en: 'https://example.com/en/about',
|
|
||||||
* // 'x-default': 'https://example.com/pl/o-nas',
|
|
||||||
* // }
|
|
||||||
*/
|
*/
|
||||||
export declare function buildHreflangAlternates({ baseUrl, config, homeSlug, prefix, slugs, }: BuildHreflangArgs): Record<string, string>;
|
export declare function buildHreflangAlternates({ slugs, config, baseUrl, homeSlug, prefix, }: BuildHreflangArgs): Record<string, string>;
|
||||||
export {};
|
export {};
|
||||||
|
|||||||
Vendored
+15
-16
@@ -1,37 +1,36 @@
|
|||||||
import { buildLocalizedPath, getDefaultLocale, getLocaleCodes } from '../i18n/index.js';
|
import { buildLocalizedPath, getLocaleCodes } from '../i18n/index.js';
|
||||||
/**
|
/**
|
||||||
|
* Builds a map of locale → URL for hreflang alternate links, suitable for
|
||||||
* Next.js Metadata `alternates.languages`.
|
* Next.js Metadata `alternates.languages`.
|
||||||
*
|
*
|
||||||
* Bridges SEO and i18n: for each configured locale that the document has a
|
* Bridges SEO and i18n: for each configured locale that the document has a
|
||||||
* slug in, it produces the locale-aware path (via buildLocalizedPath),
|
* slug in, it produces the locale-aware path (via buildLocalizedPath),
|
||||||
* optionally prefixed with an absolute origin.
|
* optionally prefixed with an absolute origin.
|
||||||
*
|
*
|
||||||
* Also emits `x-default` pointing at the default locale — the version Google
|
|
||||||
* serves when the user's language/region matches no hreflang, and the fallback
|
|
||||||
* when the root ('/') redirect is ambiguous (Googlebot with no/foreign
|
|
||||||
* Accept-Language).
|
|
||||||
*
|
|
||||||
* @example
|
* @example
|
||||||
* buildHreflangAlternates({
|
* buildHreflangAlternates({
|
||||||
* slugs: { pl: 'o-nas', en: 'about' },
|
* slugs: { pl: 'o-nas', en: 'about' },
|
||||||
* config,
|
* config,
|
||||||
* baseUrl: 'https://example.com',
|
* baseUrl: 'https://example.com',
|
||||||
* })
|
* })
|
||||||
* // → {
|
* // → { pl: 'https://example.com/pl/o-nas', en: 'https://example.com/en/about' }
|
||||||
* // pl: 'https://example.com/pl/o-nas',
|
*/ export function buildHreflangAlternates({ slugs, config, baseUrl, homeSlug = 'home', prefix }) {
|
||||||
* // en: 'https://example.com/en/about',
|
|
||||||
* // 'x-default': 'https://example.com/pl/o-nas',
|
|
||||||
* // }
|
|
||||||
*/ export function buildHreflangAlternates({ baseUrl, config, homeSlug = 'home', prefix, slugs }) {
|
|
||||||
const origin = baseUrl?.replace(/\/$/, '') ?? '';
|
const origin = baseUrl?.replace(/\/$/, '') ?? '';
|
||||||
const alternates = {};
|
const alternates = {};
|
||||||
|
// Single-locale sites have no language alternatives — hreflang describes
|
||||||
|
// relationships BETWEEN language versions, and there's only one. Emitting
|
||||||
|
// hreflang (or x-default) here would be wrong, so return empty: the page keeps
|
||||||
|
// its canonical, but no alternate-language links.
|
||||||
|
if (config.locales.length === 1) {
|
||||||
|
return alternates;
|
||||||
|
}
|
||||||
for (const locale of getLocaleCodes(config)){
|
for (const locale of getLocaleCodes(config)){
|
||||||
const path = buildLocalizedPath({
|
const path = buildLocalizedPath({
|
||||||
|
slugs,
|
||||||
|
locale,
|
||||||
config,
|
config,
|
||||||
homeSlug,
|
homeSlug,
|
||||||
locale,
|
prefix
|
||||||
prefix,
|
|
||||||
slugs
|
|
||||||
});
|
});
|
||||||
if (path) {
|
if (path) {
|
||||||
alternates[locale] = `${origin}${path}`;
|
alternates[locale] = `${origin}${path}`;
|
||||||
@@ -42,7 +41,7 @@ import { buildLocalizedPath, getDefaultLocale, getLocaleCodes } from '../i18n/in
|
|||||||
// redirect is ambiguous (Googlebot with no/foreign Accept-Language). Point it
|
// redirect is ambiguous (Googlebot with no/foreign Accept-Language). Point it
|
||||||
// at the default locale (the primary market) so search shows that version by
|
// at the default locale (the primary market) so search shows that version by
|
||||||
// default instead of guessing. Only set when the default locale has a URL.
|
// default instead of guessing. Only set when the default locale has a URL.
|
||||||
const defaultLocalePath = alternates[getDefaultLocale(config)];
|
const defaultLocalePath = alternates[config.defaultLocale];
|
||||||
if (defaultLocalePath) {
|
if (defaultLocalePath) {
|
||||||
alternates['x-default'] = defaultLocalePath;
|
alternates['x-default'] = defaultLocalePath;
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+1
-1
@@ -1 +1 @@
|
|||||||
{"version":3,"sources":["../../../src/modules/seo/hreflang.ts"],"sourcesContent":["import type { I18nConfig, LocalizedSlugs } from '../i18n/index.js'\n\nimport { buildLocalizedPath, getDefaultLocale, getLocaleCodes } from '../i18n/index.js'\n\ntype BuildHreflangArgs = {\n /** Absolute site origin, e.g. 'https://example.com'. Omit for relative paths. */\n baseUrl?: string\n config: I18nConfig\n /** Home slug that collapses to the locale root. Defaults to 'home'. */\n homeSlug?: string\n /**\n * Localized segment the document lives under (an archive page's slugs),\n * e.g. { pl: 'artykuly', en: 'articles' }. Locales missing from the prefix\n * are omitted — an entry with no archive in that language has no URL there.\n */\n prefix?: LocalizedSlugs\n /** slug per locale for the current document, e.g. { pl: 'o-nas', en: 'about' } */\n slugs: LocalizedSlugs\n}\n\n/**\n * Next.js Metadata `alternates.languages`.\n *\n * Bridges SEO and i18n: for each configured locale that the document has a\n * slug in, it produces the locale-aware path (via buildLocalizedPath),\n * optionally prefixed with an absolute origin.\n *\n * Also emits `x-default` pointing at the default locale — the version Google\n * serves when the user's language/region matches no hreflang, and the fallback\n * when the root ('/') redirect is ambiguous (Googlebot with no/foreign\n * Accept-Language).\n *\n * @example\n * buildHreflangAlternates({\n * slugs: { pl: 'o-nas', en: 'about' },\n * config,\n * baseUrl: 'https://example.com',\n * })\n * // → {\n * // pl: 'https://example.com/pl/o-nas',\n * // en: 'https://example.com/en/about',\n * // 'x-default': 'https://example.com/pl/o-nas',\n * // }\n */\nexport function buildHreflangAlternates({\n baseUrl,\n config,\n homeSlug = 'home',\n prefix,\n slugs,\n}: BuildHreflangArgs): Record<string, string> {\n const origin = baseUrl?.replace(/\\/$/, '') ?? ''\n const alternates: Record<string, string> = {}\n\n for (const locale of getLocaleCodes(config)) {\n const path = buildLocalizedPath({ config, homeSlug, locale, prefix, slugs })\n if (path) {\n alternates[locale] = `${origin}${path}`\n }\n }\n\n // x-default: the version Google serves when the user's language/region doesn't\n // match any hreflang — and, crucially here, the fallback when the root ('/')\n // redirect is ambiguous (Googlebot with no/foreign Accept-Language). Point it\n // at the default locale (the primary market) so search shows that version by\n // default instead of guessing. Only set when the default locale has a URL.\n const defaultLocalePath = alternates[getDefaultLocale(config)]\n if (defaultLocalePath) {\n alternates['x-default'] = defaultLocalePath\n }\n\n return alternates\n}\n"],"names":["buildLocalizedPath","getDefaultLocale","getLocaleCodes","buildHreflangAlternates","baseUrl","config","homeSlug","prefix","slugs","origin","replace","alternates","locale","path","defaultLocalePath"],"mappings":"AAEA,SAASA,kBAAkB,EAAEC,gBAAgB,EAAEC,cAAc,QAAQ,mBAAkB;AAkBvF;;;;;;;;;;;;;;;;;;;;;;;CAuBC,GACD,OAAO,SAASC,wBAAwB,EACtCC,OAAO,EACPC,MAAM,EACNC,WAAW,MAAM,EACjBC,MAAM,EACNC,KAAK,EACa;IAClB,MAAMC,SAASL,SAASM,QAAQ,OAAO,OAAO;IAC9C,MAAMC,aAAqC,CAAC;IAE5C,KAAK,MAAMC,UAAUV,eAAeG,QAAS;QAC3C,MAAMQ,OAAOb,mBAAmB;YAAEK;YAAQC;YAAUM;YAAQL;YAAQC;QAAM;QAC1E,IAAIK,MAAM;YACRF,UAAU,CAACC,OAAO,GAAG,GAAGH,SAASI,MAAM;QACzC;IACF;IAEA,+EAA+E;IAC/E,6EAA6E;IAC7E,8EAA8E;IAC9E,6EAA6E;IAC7E,2EAA2E;IAC3E,MAAMC,oBAAoBH,UAAU,CAACV,iBAAiBI,QAAQ;IAC9D,IAAIS,mBAAmB;QACrBH,UAAU,CAAC,YAAY,GAAGG;IAC5B;IAEA,OAAOH;AACT"}
|
{"version":3,"sources":["../../../src/modules/seo/hreflang.ts"],"sourcesContent":["import type { I18nConfig, LocalizedSlugs } from '../i18n/index.js'\nimport { buildLocalizedPath, getLocaleCodes } from '../i18n/index.js'\n\ntype BuildHreflangArgs = {\n /** slug per locale for the current document, e.g. { pl: 'o-nas', en: 'about' } */\n slugs: LocalizedSlugs\n config: I18nConfig\n /** Absolute site origin, e.g. 'https://example.com'. Omit for relative paths. */\n baseUrl?: string\n /** Home slug that collapses to the locale root. Defaults to 'home'. */\n homeSlug?: string | Record<string, string>\n /**\n * Localized segment the document lives under (an archive page's slugs),\n * e.g. { pl: 'artykuly', en: 'articles' }. Locales missing from the prefix\n * are omitted — an entry with no archive in that language has no URL there.\n */\n prefix?: LocalizedSlugs\n}\n\n/**\n * Builds a map of locale → URL for hreflang alternate links, suitable for\n * Next.js Metadata `alternates.languages`.\n *\n * Bridges SEO and i18n: for each configured locale that the document has a\n * slug in, it produces the locale-aware path (via buildLocalizedPath),\n * optionally prefixed with an absolute origin.\n *\n * @example\n * buildHreflangAlternates({\n * slugs: { pl: 'o-nas', en: 'about' },\n * config,\n * baseUrl: 'https://example.com',\n * })\n * // → { pl: 'https://example.com/pl/o-nas', en: 'https://example.com/en/about' }\n */\nexport function buildHreflangAlternates({\n slugs,\n config,\n baseUrl,\n homeSlug = 'home',\n prefix,\n}: BuildHreflangArgs): Record<string, string> {\n const origin = baseUrl?.replace(/\\/$/, '') ?? ''\n const alternates: Record<string, string> = {}\n\n // Single-locale sites have no language alternatives — hreflang describes\n // relationships BETWEEN language versions, and there's only one. Emitting\n // hreflang (or x-default) here would be wrong, so return empty: the page keeps\n // its canonical, but no alternate-language links.\n if (config.locales.length === 1) {\n return alternates\n }\n\n for (const locale of getLocaleCodes(config)) {\n const path = buildLocalizedPath({ slugs, locale, config, homeSlug, prefix })\n if (path) {\n alternates[locale] = `${origin}${path}`\n }\n }\n\n // x-default: the version Google serves when the user's language/region doesn't\n // match any hreflang — and, crucially here, the fallback when the root ('/')\n // redirect is ambiguous (Googlebot with no/foreign Accept-Language). Point it\n // at the default locale (the primary market) so search shows that version by\n // default instead of guessing. Only set when the default locale has a URL.\n const defaultLocalePath = alternates[config.defaultLocale]\n if (defaultLocalePath) {\n alternates['x-default'] = defaultLocalePath\n }\n\n return alternates\n}\n"],"names":["buildLocalizedPath","getLocaleCodes","buildHreflangAlternates","slugs","config","baseUrl","homeSlug","prefix","origin","replace","alternates","locales","length","locale","path","defaultLocalePath","defaultLocale"],"mappings":"AACA,SAASA,kBAAkB,EAAEC,cAAc,QAAQ,mBAAkB;AAkBrE;;;;;;;;;;;;;;;CAeC,GACD,OAAO,SAASC,wBAAwB,EACtCC,KAAK,EACLC,MAAM,EACNC,OAAO,EACPC,WAAW,MAAM,EACjBC,MAAM,EACY;IAClB,MAAMC,SAASH,SAASI,QAAQ,OAAO,OAAO;IAC9C,MAAMC,aAAqC,CAAC;IAE5C,yEAAyE;IACzE,0EAA0E;IAC1E,+EAA+E;IAC/E,kDAAkD;IAClD,IAAIN,OAAOO,OAAO,CAACC,MAAM,KAAK,GAAG;QAC/B,OAAOF;IACT;IAEA,KAAK,MAAMG,UAAUZ,eAAeG,QAAS;QAC3C,MAAMU,OAAOd,mBAAmB;YAAEG;YAAOU;YAAQT;YAAQE;YAAUC;QAAO;QAC1E,IAAIO,MAAM;YACRJ,UAAU,CAACG,OAAO,GAAG,GAAGL,SAASM,MAAM;QACzC;IACF;IAEA,+EAA+E;IAC/E,6EAA6E;IAC7E,8EAA8E;IAC9E,6EAA6E;IAC7E,2EAA2E;IAC3E,MAAMC,oBAAoBL,UAAU,CAACN,OAAOY,aAAa,CAAC;IAC1D,IAAID,mBAAmB;QACrBL,UAAU,CAAC,YAAY,GAAGK;IAC5B;IAEA,OAAOL;AACT"}
|
||||||
Vendored
+5
@@ -1,12 +1,17 @@
|
|||||||
export { buildAutoFillMetaHook } from './autoFillMeta.js';
|
export { buildAutoFillMetaHook } from './autoFillMeta.js';
|
||||||
export type { AutoFillMapping } from './autoFillMeta.js';
|
export type { AutoFillMapping } from './autoFillMeta.js';
|
||||||
|
export { buildArticleJsonLd } from './buildArticleJsonLd.js';
|
||||||
export { buildBreadcrumbJsonLd } from './buildBreadcrumbJsonLd.js';
|
export { buildBreadcrumbJsonLd } from './buildBreadcrumbJsonLd.js';
|
||||||
|
export { buildFaqJsonLd } from './buildFaqJsonLd.js';
|
||||||
export { buildIconsMetadata } from './buildIconsMetadata.js';
|
export { buildIconsMetadata } from './buildIconsMetadata.js';
|
||||||
|
export { buildLlmsTxt } from './buildLlmsTxt.js';
|
||||||
|
export { buildLocalBusinessJsonLd } from './buildLocalBusinessJsonLd.js';
|
||||||
export { buildMetadata } from './buildMetadata.js';
|
export { buildMetadata } from './buildMetadata.js';
|
||||||
export type { PageMetadata } from './buildMetadata.js';
|
export type { PageMetadata } from './buildMetadata.js';
|
||||||
export { buildOrganizationJsonLd } from './buildOrganizationJsonLd.js';
|
export { buildOrganizationJsonLd } from './buildOrganizationJsonLd.js';
|
||||||
export { buildRobots } from './buildRobots.js';
|
export { buildRobots } from './buildRobots.js';
|
||||||
export type { RobotsRules } from './buildRobots.js';
|
export type { RobotsRules } from './buildRobots.js';
|
||||||
|
export { buildServiceJsonLd } from './buildServiceJsonLd.js';
|
||||||
export { buildSitemapEntries } from './buildSitemapEntries.js';
|
export { buildSitemapEntries } from './buildSitemapEntries.js';
|
||||||
export type { SitemapEntry } from './buildSitemapEntries.js';
|
export type { SitemapEntry } from './buildSitemapEntries.js';
|
||||||
export { buildSiteNavigationJsonLd } from './buildSiteNavigationJsonLd.js';
|
export { buildSiteNavigationJsonLd } from './buildSiteNavigationJsonLd.js';
|
||||||
|
|||||||
Vendored
+5
@@ -1,9 +1,14 @@
|
|||||||
export { buildAutoFillMetaHook } from './autoFillMeta.js';
|
export { buildAutoFillMetaHook } from './autoFillMeta.js';
|
||||||
|
export { buildArticleJsonLd } from './buildArticleJsonLd.js';
|
||||||
export { buildBreadcrumbJsonLd } from './buildBreadcrumbJsonLd.js';
|
export { buildBreadcrumbJsonLd } from './buildBreadcrumbJsonLd.js';
|
||||||
|
export { buildFaqJsonLd } from './buildFaqJsonLd.js';
|
||||||
export { buildIconsMetadata } from './buildIconsMetadata.js';
|
export { buildIconsMetadata } from './buildIconsMetadata.js';
|
||||||
|
export { buildLlmsTxt } from './buildLlmsTxt.js';
|
||||||
|
export { buildLocalBusinessJsonLd } from './buildLocalBusinessJsonLd.js';
|
||||||
export { buildMetadata } from './buildMetadata.js';
|
export { buildMetadata } from './buildMetadata.js';
|
||||||
export { buildOrganizationJsonLd } from './buildOrganizationJsonLd.js';
|
export { buildOrganizationJsonLd } from './buildOrganizationJsonLd.js';
|
||||||
export { buildRobots } from './buildRobots.js';
|
export { buildRobots } from './buildRobots.js';
|
||||||
|
export { buildServiceJsonLd } from './buildServiceJsonLd.js';
|
||||||
export { buildSitemapEntries } from './buildSitemapEntries.js';
|
export { buildSitemapEntries } from './buildSitemapEntries.js';
|
||||||
export { buildSiteNavigationJsonLd } from './buildSiteNavigationJsonLd.js';
|
export { buildSiteNavigationJsonLd } from './buildSiteNavigationJsonLd.js';
|
||||||
export { buildWebSiteJsonLd } from './buildWebSiteJsonLd.js';
|
export { buildWebSiteJsonLd } from './buildWebSiteJsonLd.js';
|
||||||
|
|||||||
Vendored
+1
-1
@@ -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"}
|
{"version":3,"sources":["../../../src/modules/seo/index.ts"],"sourcesContent":["export { buildAutoFillMetaHook } from './autoFillMeta.js'\nexport type { AutoFillMapping } from './autoFillMeta.js'\nexport { buildArticleJsonLd } from './buildArticleJsonLd.js'\nexport { buildBreadcrumbJsonLd } from './buildBreadcrumbJsonLd.js'\nexport { buildFaqJsonLd } from './buildFaqJsonLd.js'\nexport { buildIconsMetadata } from './buildIconsMetadata.js'\nexport { buildLlmsTxt } from './buildLlmsTxt.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","buildArticleJsonLd","buildBreadcrumbJsonLd","buildFaqJsonLd","buildIconsMetadata","buildLlmsTxt","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,kBAAkB,QAAQ,0BAAyB;AAC5D,SAASC,qBAAqB,QAAQ,6BAA4B;AAClE,SAASC,cAAc,QAAQ,sBAAqB;AACpD,SAASC,kBAAkB,QAAQ,0BAAyB;AAC5D,SAASC,YAAY,QAAQ,oBAAmB;AAChD,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"}
|
||||||
+9
-2
@@ -1,11 +1,18 @@
|
|||||||
import type { BasePayload } from 'payload';
|
import type { BasePayload } from 'payload';
|
||||||
import type { TitleOrder } from './composeTitle.js';
|
import type { TitleOrder } from './composeTitle.js';
|
||||||
export type SiteMetaConfig = {
|
export type SiteMetaConfig = {
|
||||||
/** Slug of the page assigned as Homepage; collapses to the locale root. */
|
/**
|
||||||
homeSlug?: string;
|
* Home slug per locale, e.g. { pl: 'strona-glowna', de: 'startseite' }. A map
|
||||||
|
* (not a single string) so multilingual homepages collapse to root in EVERY
|
||||||
|
* language — otherwise hreflang points other languages at '/de/startseite'
|
||||||
|
* instead of '/de', breaking return tags (a real GSC error).
|
||||||
|
*/
|
||||||
|
homeSlug?: Record<string, string>;
|
||||||
order?: TitleOrder;
|
order?: TitleOrder;
|
||||||
/** Padded separator, e.g. ' | ' — the panel stores the bare character. */
|
/** Padded separator, e.g. ' | ' — the panel stores the bare character. */
|
||||||
separator?: string;
|
separator?: string;
|
||||||
|
/** Global fallback meta description. */
|
||||||
|
siteDescription?: null | string;
|
||||||
siteName: null | string;
|
siteName: null | string;
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
|
|||||||
+45
-1
@@ -14,10 +14,54 @@
|
|||||||
locale: locale
|
locale: locale
|
||||||
});
|
});
|
||||||
const homepage = settings.homepage && typeof settings.homepage === 'object' ? settings.homepage : null;
|
const homepage = settings.homepage && typeof settings.homepage === 'object' ? settings.homepage : null;
|
||||||
|
// Build the per-locale home slug map. The homepage's slug is localized, so a
|
||||||
|
// single-locale read only gives THIS language's slug. Re-read the homepage
|
||||||
|
// with locale:'all' to get every language's slug — needed so hreflang can
|
||||||
|
// collapse each language's home to its root.
|
||||||
|
let homeSlug;
|
||||||
|
if (homepage?.id) {
|
||||||
|
try {
|
||||||
|
const homeDoc = await payload.findByID({
|
||||||
|
id: homepage.id,
|
||||||
|
collection: 'pages',
|
||||||
|
depth: 0,
|
||||||
|
locale: 'all'
|
||||||
|
});
|
||||||
|
const slugField = homeDoc.slug;
|
||||||
|
if (slugField && typeof slugField === 'object') {
|
||||||
|
const map = {};
|
||||||
|
for (const [loc, value] of Object.entries(slugField)){
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
map[loc] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Object.keys(map).length > 0) {
|
||||||
|
homeSlug = map;
|
||||||
|
}
|
||||||
|
} else if (typeof slugField === 'string') {
|
||||||
|
// Non-localized slug (same in every language) — key it by current locale;
|
||||||
|
// buildLocalizedPath tolerates a partial map. Non-localized homepage
|
||||||
|
// slugs are rare, so this branch is a safety net, not the norm.
|
||||||
|
homeSlug = {
|
||||||
|
[locale]: slugField
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall back to the single-locale slug below.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback: if the 'all' read didn't yield a map, use the current locale's slug
|
||||||
|
// for at least this locale (better than nothing).
|
||||||
|
if (!homeSlug && typeof homepage?.slug === 'string') {
|
||||||
|
homeSlug = {
|
||||||
|
[locale]: homepage.slug
|
||||||
|
};
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
|
siteDescription: settings.siteDescription ?? null,
|
||||||
siteName: settings[siteNameField] ?? null,
|
siteName: settings[siteNameField] ?? null,
|
||||||
// The panel stores '|'; titles need it padded.
|
// The panel stores '|'; titles need it padded.
|
||||||
homeSlug: typeof homepage?.slug === 'string' ? homepage.slug : undefined,
|
homeSlug,
|
||||||
order: settings.titleOrder ?? undefined,
|
order: settings.titleOrder ?? undefined,
|
||||||
separator: settings.titleSeparator ? ` ${settings.titleSeparator} ` : undefined
|
separator: settings.titleSeparator ? ` ${settings.titleSeparator} ` : undefined
|
||||||
};
|
};
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
@@ -34,6 +34,7 @@ export type SeoOption = {
|
|||||||
export type SeoMeta = {
|
export type SeoMeta = {
|
||||||
description?: null | string;
|
description?: null | string;
|
||||||
image?: unknown;
|
image?: unknown;
|
||||||
|
noindex?: boolean | null;
|
||||||
title?: null | string;
|
title?: null | string;
|
||||||
/** When set, used as the whole title — no site name, no separator. */
|
/** When set, used as the whole title — no site name, no separator. */
|
||||||
titleOverride?: null | string;
|
titleOverride?: null | string;
|
||||||
|
|||||||
Vendored
+1
-1
@@ -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, unknown> }) => string\n /** Optional: customize how meta titles are generated in the admin preview. */\n generateTitle?: (args: { doc: Record<string, unknown> }) => 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"}
|
{"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, unknown> }) => string\n /** Optional: customize how meta titles are generated in the admin preview. */\n generateTitle?: (args: { doc: Record<string, unknown> }) => 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"}
|
||||||
@@ -10,6 +10,30 @@ registry (prop), obsługuje zagnieżdżanie i rozszerzenia per-blok. Bloki
|
|||||||
Brak opcji w payload.config — bloki definiujesz w swoich kolekcjach
|
Brak opcji w payload.config — bloki definiujesz w swoich kolekcjach
|
||||||
(pole typu `blocks`). Plugin dostarcza tylko silnik renderujący.
|
(pole typu `blocks`). Plugin dostarcza tylko silnik renderujący.
|
||||||
|
|
||||||
|
## ⚠️ NIE pisz własnego renderera bloków (switch)
|
||||||
|
|
||||||
|
**Renderer bloków to `RenderBlocks` z pluginu — NIGDY własny `switch`/`if`.**
|
||||||
|
Częsty błąd: projekt pisze własny `BlockRenderer` z `switch (block.blockType)`
|
||||||
|
i 20 case'ami. To łamie A0 — plugin ma silnik, projekt dostarcza tylko MAPĘ
|
||||||
|
komponentów.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ŹLE — własny switch w projekcie (gadatliwy, bez enhanceProps, rośnie liniowo)
|
||||||
|
switch (block.blockType) {
|
||||||
|
case 'hero': return <Hero {...block} />
|
||||||
|
case 'faq': return <FAQ {...block} />
|
||||||
|
// ...20 case'ów
|
||||||
|
}
|
||||||
|
|
||||||
|
// DOBRZE — mapa + RenderBlocks (silnik z pluginu)
|
||||||
|
const registry = { hero: Hero, faq: FAQ, /* ... */ }
|
||||||
|
<RenderBlocks blocks={page.layout} components={registry} />
|
||||||
|
```
|
||||||
|
|
||||||
|
Dlaczego RenderBlocks, nie switch: enhanceProps (anchory nav, itp.), guardy,
|
||||||
|
obsługa zagnieżdżeń, spójność między projektami. Switch tego nie ma i rośnie
|
||||||
|
z każdym blokiem. Mapa jest płaska i deklaratywna.
|
||||||
|
|
||||||
## Front — RenderBlocks
|
## Front — RenderBlocks
|
||||||
|
|
||||||
Import z `@intecion/ipal-kit/rsc` (to komponent serwerowy):
|
Import z `@intecion/ipal-kit/rsc` (to komponent serwerowy):
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
# 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=<losowy-ciąg-min-32-znaki>
|
||||||
|
|
||||||
|
# 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=<azure-tenant-id>
|
||||||
|
GRAPH_CLIENT_ID=<azure-app-client-id>
|
||||||
|
GRAPH_CLIENT_SECRET=<azure-app-secret>
|
||||||
|
GRAPH_SENDER=[email protected] # 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=<nazwa-bucketa>
|
||||||
|
R2_ENDPOINT=https://<ACCOUNT_ID>.r2.cloudflarestorage.com
|
||||||
|
R2_ACCESS_KEY_ID=<access-key>
|
||||||
|
R2_SECRET_ACCESS_KEY=<secret-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 <body> zamiast <head> (htmlLimitedBots)
|
||||||
|
|
||||||
|
Next 16 streamuje metadata dynamicznych stron do `<body>` (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 `<head>`.
|
||||||
|
Szczegóły i objawy: seo.md (sekcja htmlLimitedBots).
|
||||||
|
|
||||||
|
## 3a2. ISR — cache stron (metadata w head + szybkość)
|
||||||
|
|
||||||
|
Dla stron contentowych (page.tsx) użyj ISR: `export const revalidate = 3600`.
|
||||||
|
Cache'uje całą stronę z gotowym `<head>` → metadata zawsze w head (nie body,
|
||||||
|
brak race condition streamingu), TTFB ~20ms, brak 503 (cold start).
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// app/(frontend)/[locale]/[[...slug]]/page.tsx
|
||||||
|
export const revalidate = 3600 // 1h; albo krócej, albo on-demand
|
||||||
|
```
|
||||||
|
|
||||||
|
UWAGA: ISR i `force-dynamic` się WYKLUCZAJĄ. Strony → ISR (revalidate).
|
||||||
|
sitemap/robots → force-dynamic (bo generują przy żądaniu). Nie mieszaj na jednej
|
||||||
|
trasie. Treść z panelu: ISR = redaktor czeka do rewalidacji; rozważ on-demand
|
||||||
|
revalidation (hook afterChange → revalidatePath). Patrz seo.md, HOOKS.md.
|
||||||
|
|
||||||
|
## 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 `<head>` (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
|
||||||
@@ -359,7 +359,11 @@ export default async function LocaleLayout({ children, params }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang={locale}>
|
<html lang={locale}>
|
||||||
|
{/* BEZ jawnego <head>! Sztywny <head> wypycha metadata do <body>
|
||||||
|
(canonical/title poza head → crawlery ich nie widzą). Next zarządza
|
||||||
|
<head> sam; MediaPreconnect w body, React 19 hoistuje link do head. */}
|
||||||
<body>
|
<body>
|
||||||
|
<MediaPreconnect /> {/* preconnect CDN, jeśli R2 */}
|
||||||
<ConsentProvider texts={texts}>
|
<ConsentProvider texts={texts}>
|
||||||
<main>{children}</main>
|
<main>{children}</main>
|
||||||
<CookieBanner />
|
<CookieBanner />
|
||||||
@@ -392,6 +396,11 @@ const pageMetadata = createPageMetadata({
|
|||||||
baseUrl: process.env.NEXT_PUBLIC_SERVER_URL,
|
baseUrl: process.env.NEXT_PUBLIC_SERVER_URL,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ISR — cache strony z gotowym <head>. Eliminuje race condition streamingu
|
||||||
|
// metadata (canonical/title zawsze w head, nie w body). TTFB ~20ms, brak 503.
|
||||||
|
// Redaktor widzi zmiany po rewalidacji — patrz seo.md (ISR a treść z panelu).
|
||||||
|
export const revalidate = 3600
|
||||||
|
|
||||||
export async function generateMetadata({ params }): Promise<Metadata> {
|
export async function generateMetadata({ params }): Promise<Metadata> {
|
||||||
const { locale, slug } = await params
|
const { locale, slug } = await params
|
||||||
return pageMetadata({ payload: await getCachedPayload(), locale, slug })
|
return pageMetadata({ payload: await getCachedPayload(), locale, slug })
|
||||||
@@ -408,6 +417,8 @@ export default async function Page({ params, searchParams }) {
|
|||||||
|
|
||||||
- brak slug (`/pl`) → home przez System Pages (nie hardkod slug)
|
- brak slug (`/pl`) → home przez System Pages (nie hardkod slug)
|
||||||
- slug (`/pl/o-nas`) → resolveRoute po slug w danym locale
|
- slug (`/pl/o-nas`) → resolveRoute po slug w danym locale
|
||||||
|
- **ISR (`revalidate`)** → metadata zawsze w `<head>` (nie body), szybki TTFB.
|
||||||
|
KRYTYCZNE dla SEO — patrz seo.md (metadata w head).
|
||||||
- `depth: 2` → relacje w blokach (form) się populują
|
- `depth: 2` → relacje w blokach (form) się populują
|
||||||
|
|
||||||
## 11. Metadata / SEO (szczegóły)
|
## 11. Metadata / SEO (szczegóły)
|
||||||
@@ -486,10 +497,15 @@ przypisanie strony-archiwum w System Pages.
|
|||||||
```ts
|
```ts
|
||||||
// app/sitemap.ts
|
// app/sitemap.ts
|
||||||
export { sitemap as default } from '@/lib/content'
|
export { sitemap as default } from '@/lib/content'
|
||||||
|
export const dynamic = 'force-dynamic' // KONIECZNE dla deployu kontenerowego
|
||||||
// app/robots.ts
|
// app/robots.ts
|
||||||
export { robots as default } from '@/lib/content'
|
export { robots as default } from '@/lib/content'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`force-dynamic` w sitemap.ts jest wymagane przy deployu w kontenerze (Coolify/
|
||||||
|
Docker) — bez niego build próbuje prerenderować sitemap i łączy się z bazą,
|
||||||
|
której kontener budujący nie widzi → build pada. Szczegóły: [deployment.md](./deployment.md).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Kiedy coś nie działa
|
## Kiedy coś nie działa
|
||||||
|
|||||||
+110
-1
@@ -55,6 +55,21 @@ buildLocalizedPath({ slugs, locale: 'en', config }) // '/en/about'
|
|||||||
buildLocalizedPath({ slugs: { en: 'home' }, locale: 'en', config }) // '/en'
|
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<string, unknown>,
|
||||||
|
> 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)
|
### Przełącznik języka (bez 404)
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
@@ -139,4 +154,98 @@ Zmiana języka (URL `/en` różny od cookie) → zapis nowego wyboru (za zgodą)
|
|||||||
|
|
||||||
> **Migracja ze starej nazwy:** wcześniej cookie nazywało się `ipal-locale`.
|
> **Migracja ze starej nazwy:** wcześniej cookie nazywało się `ipal-locale`.
|
||||||
> Po zmianie na `NEXT_LOCALE` użytkownicy ze starą cookie przejdą raz ponowną
|
> Po zmianie na `NEXT_LOCALE` użytkownicy ze starą cookie przejdą raz ponowną
|
||||||
> negocjację (stara cookie ignorowana). Jednorazowe, bez wpływu na nowe projekty.
|
> negocjację (stara cookie ignorowana). Jednorazowe, bez wpływu na nowe projekty.
|
||||||
|
|
||||||
|
## Strona jednojęzyczna (bez prefiksu /pl)
|
||||||
|
|
||||||
|
Gdy projekt ma JEDEN język, adresy nie mają prefiksu locale: `/o-nas`, nie
|
||||||
|
`/pl/o-nas`. Plugin wykrywa to automatycznie — **jeden locale w config = tryb
|
||||||
|
jednojęzyczny**. Helpery (buildLocalizedPath, hreflang, middleware) dostosowują
|
||||||
|
się same:
|
||||||
|
|
||||||
|
- **buildLocalizedPath** → `/o-nas` (bez `/pl`), home → `/`
|
||||||
|
- **buildHreflangAlternates** → pusto (jeden język = brak alternatyw językowych)
|
||||||
|
- **localeMiddleware** → pass-through (brak przekierowania `/` → `/pl`, brak negocjacji)
|
||||||
|
- **canonical** → `https://klient.pl/o-nas` (bez prefiksu)
|
||||||
|
|
||||||
|
### Config — jeden locale
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// i18n.config.ts
|
||||||
|
export const i18nConfig = {
|
||||||
|
locales: [{ code: 'pl', label: 'Polski' }], // JEDEN locale
|
||||||
|
defaultLocale: 'pl',
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Struktura katalogów — BEZ [locale]
|
||||||
|
|
||||||
|
To kluczowa różnica. Projekt jednojęzyczny NIE ma folderu `[locale]`:
|
||||||
|
|
||||||
|
```
|
||||||
|
# JEDNOJĘZYCZNY (bez [locale])
|
||||||
|
app/(frontend)/
|
||||||
|
layout.tsx # locale stałe z config, nie z params
|
||||||
|
not-found.tsx
|
||||||
|
[[...slug]]/page.tsx # /o-nas, /kontakt
|
||||||
|
|
||||||
|
# WIELOJĘZYCZNY (z [locale]) — dla porównania
|
||||||
|
app/(frontend)/[locale]/
|
||||||
|
layout.tsx # locale z params
|
||||||
|
[[...slug]]/page.tsx # /pl/o-nas, /en/about
|
||||||
|
```
|
||||||
|
|
||||||
|
### Layout jednojęzyczny — locale z config
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app/(frontend)/layout.tsx (bez [locale])
|
||||||
|
import { i18nConfig } from '@/i18n.config'
|
||||||
|
|
||||||
|
export default async function Layout({ children }: { children: React.ReactNode }) {
|
||||||
|
const locale = i18nConfig.defaultLocale // stałe, nie z params
|
||||||
|
const settings = await getSettings(locale)
|
||||||
|
// ...reszta jak zwykle, ale locale jest stałe
|
||||||
|
return <html lang={locale}>...</html>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Strony jednojęzyczne — locale z config
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app/(frontend)/[[...slug]]/page.tsx (bez [locale])
|
||||||
|
import { i18nConfig } from '@/i18n.config'
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }) {
|
||||||
|
const { slug } = await params // TYLKO slug, nie locale
|
||||||
|
const locale = i18nConfig.defaultLocale // stałe
|
||||||
|
return pageMetadata({ payload: await getCachedPayload(), locale, slug })
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function Page({ params }) {
|
||||||
|
const { slug } = await params
|
||||||
|
const locale = i18nConfig.defaultLocale // stałe
|
||||||
|
const route = await resolveRoute(locale, slug ?? [], pageNum)
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
resolveRoute i inne helpery działają bez zmian — dostają stałe locale z config
|
||||||
|
zamiast z URL. Cała różnica to: brak `[locale]` w strukturze, locale z config.
|
||||||
|
|
||||||
|
### Middleware/proxy — jednojęzyczny prawie go nie potrzebuje
|
||||||
|
|
||||||
|
Dla jednego locale middleware jest pass-through (nic nie przekierowuje). Możesz
|
||||||
|
go pominąć albo zostawić — plugin i tak wykryje 1 locale i przepuści. Bez
|
||||||
|
przełącznika języka (jeden język), bez cookie NEXT_LOCALE (nie ma co pamiętać).
|
||||||
|
|
||||||
|
### Przejście jedno- → wielojęzyczny (later)
|
||||||
|
|
||||||
|
Jeśli klient później doda drugi język, to PRZEBUDOWA, nie przełącznik:
|
||||||
|
- dodaj locale do config
|
||||||
|
- przenieś strukturę do `[locale]/`
|
||||||
|
- layout/strony czytają locale z params
|
||||||
|
- wróci prefiks `/pl`, `/en` + hreflang
|
||||||
|
|
||||||
|
Warto to przewidzieć na starcie: jeśli jest szansa na drugi język, rozważ od razu
|
||||||
|
strukturę wielojęzyczną (z [locale]), nawet dla jednego locale — wtedy prefiks
|
||||||
|
`/pl` jest, ale dodanie języka to tylko config, nie przebudowa struktury.
|
||||||
+78
-1
@@ -61,4 +61,81 @@ ZAWSZE wyłączaj w dev: `hsts: process.env.NODE_ENV === 'production'`.
|
|||||||
|
|
||||||
`additional` z tym samym kluczem NADPISUJE domyślny (np. zmień X-Frame-Options
|
`additional` z tym samym kluczem NADPISUJE domyślny (np. zmień X-Frame-Options
|
||||||
na SAMEORIGIN). Nowy klucz (jak CSP) dodaje. CSP zawsze przez `additional` —
|
na SAMEORIGIN). Nowy klucz (jak CSP) dodaje. CSP zawsze przez `additional` —
|
||||||
plugin go nie generuje, bo zależy od projektu.
|
plugin go nie generuje, bo zależy od projektu.
|
||||||
|
|
||||||
|
### Dlaczego CSP zostaje w projekcie (nie plugin)
|
||||||
|
|
||||||
|
HSTS, nosniff, Referrer-Policy są IDENTYCZNE dla każdego projektu → plugin je
|
||||||
|
generuje. CSP wylicza KONKRETNE domeny, z których projekt ładuje (jego R2,
|
||||||
|
analytics, Turnstile, fonty). Generyczny CSP byłby albo za luźny (`*` =
|
||||||
|
bezużyteczny), albo psułby stronę. Więc plugin daje mechanizm (`additional`),
|
||||||
|
projekt dostarcza CSP dopasowany do siebie.
|
||||||
|
|
||||||
|
### Budowa CSP — domeny z env, nie hardkod
|
||||||
|
|
||||||
|
Domenę mediów czytaj z `R2_PUBLIC_URL` (env), nie zaszywaj. Resztę źródeł
|
||||||
|
dopasuj do tego, co projekt faktycznie ładuje:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// next.config.ts
|
||||||
|
const r2Url = process.env.R2_PUBLIC_URL || ''
|
||||||
|
|
||||||
|
const csp = [
|
||||||
|
"default-src 'self'",
|
||||||
|
// skrypty: self + Turnstile (Cloudflare) + analytics (GTM/GA jeśli używasz)
|
||||||
|
"script-src 'self' 'unsafe-inline' https://challenges.cloudflare.com https://www.googletagmanager.com",
|
||||||
|
// style: self + inline (Tailwind) + Google Fonts
|
||||||
|
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
|
||||||
|
// obrazy: self + media R2 (z env!) + data:
|
||||||
|
`img-src 'self' data: ${r2Url}`.trim(),
|
||||||
|
"font-src 'self' https://fonts.gstatic.com data:",
|
||||||
|
"connect-src 'self' https://www.google-analytics.com",
|
||||||
|
// ramki: Turnstile (widget captcha)
|
||||||
|
"frame-src https://challenges.cloudflare.com",
|
||||||
|
"form-action 'self'",
|
||||||
|
"frame-ancestors 'none'", // zastępuje X-Frame-Options w nowych przeglądarkach
|
||||||
|
].join('; ')
|
||||||
|
|
||||||
|
const securityHeaders = buildSecurityHeaders({
|
||||||
|
hsts: process.env.NODE_ENV === 'production',
|
||||||
|
additional: [{ key: 'Content-Security-Policy', value: csp }],
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Dopasuj źródła do projektu: mapy Google (`https://maps.googleapis.com`,
|
||||||
|
`https://*.google.com`), inne embedy, inne analytics. To, czego nie wymienisz,
|
||||||
|
zostanie zablokowane.
|
||||||
|
|
||||||
|
### WDRAŻAJ CSP OSTROŻNIE — najpierw Report-Only
|
||||||
|
|
||||||
|
CSP za ścisły **psuje stronę** (blokuje skrypty/style/obrazy). NIGDY nie wdrażaj
|
||||||
|
enforcing CSP na ślepo. Metoda bezpieczna:
|
||||||
|
|
||||||
|
1. **Najpierw raportowanie** — użyj klucza `Content-Security-Policy-Report-Only`
|
||||||
|
(nie `Content-Security-Policy`). Przeglądarka RAPORTUJE naruszenia w konsoli,
|
||||||
|
ale NIE blokuje — strona działa normalnie.
|
||||||
|
```ts
|
||||||
|
additional: [{ key: 'Content-Security-Policy-Report-Only', value: csp }]
|
||||||
|
```
|
||||||
|
2. **Otwórz stronę** → DevTools → Console → szukaj „Content Security Policy"
|
||||||
|
violations. Każde naruszenie = brakująca domena. Dodaj ją do odpowiedniej
|
||||||
|
dyrektywy CSP.
|
||||||
|
3. **Przejdź przez cały serwis** — strona główna, formularze (Turnstile!),
|
||||||
|
galeria (obrazy R2), strony z mapą/embedami. Zbierz wszystkie naruszenia.
|
||||||
|
4. **Dopiero gdy konsola czysta** → zmień klucz na `Content-Security-Policy`
|
||||||
|
(enforcing). Teraz CSP chroni, nie psując.
|
||||||
|
|
||||||
|
### Weryfikacja nagłówków na produkcji
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# sprawdź, które nagłówki faktycznie wychodzą:
|
||||||
|
curl -sI https://<DOMENA>/pl | grep -i "strict-transport\|content-type-options\|referrer\|content-security\|x-frame"
|
||||||
|
```
|
||||||
|
|
||||||
|
Jeśli HSTS/nosniff/Referrer są, a CSP brak → dodaj CSP (wyżej). Jeśli BRAK
|
||||||
|
wszystkich mimo buildSecurityHeaders w config → sprawdź, czy `headers()` jest
|
||||||
|
wpięte i czy Cloudflare (jeśli przed aplikacją) nie filtruje nagłówków.
|
||||||
|
|
||||||
|
> Uwaga Cloudflare: jeśli CF jest przed aplikacją, może nadpisywać/filtrować
|
||||||
|
> nagłówki. Wtedy ustaw je też w CF (Transform Rules → Modify Response Header)
|
||||||
|
> albo upewnij się, że CF przepuszcza nagłówki z origin.
|
||||||
+372
-24
@@ -53,6 +53,31 @@ Per strona (tab SEO):
|
|||||||
|
|
||||||
Domyślnie: `Tytuł | Nazwa witryny`.
|
Domyślnie: `Tytuł | Nazwa witryny`.
|
||||||
|
|
||||||
|
### Skąd bierze się „Tytuł" (priorytet źródła)
|
||||||
|
|
||||||
|
Tytuł strony (część przed nazwą witryny) pochodzi z, w kolejności:
|
||||||
|
|
||||||
|
1. **titleOverride** — jeśli wypełniony, jest całym tytułem (bez składania).
|
||||||
|
2. **meta.title** — tytuł SEO wpisany w tab SEO.
|
||||||
|
3. **page.title** — nazwa dokumentu (np. „Sprzątanie biur”), gdy meta.title puste.
|
||||||
|
|
||||||
|
Punkt 3 (fallback na nazwę strony) działa na dwa sposoby, uzupełniające się:
|
||||||
|
|
||||||
|
- **buildAutoFillMetaHook** (przy ZAPISIE) — wypełnia puste `meta.title` z pola
|
||||||
|
dokumentu (`title`). Jeśli wpięty w kolekcje, meta.title nigdy nie jest puste.
|
||||||
|
- **pageTitle w buildMetadata** (przy RENDEROWANIU) — jeśli meta.title mimo to
|
||||||
|
puste (np. auto-fill niewpięty), używa `page.title`. Druga linia obrony.
|
||||||
|
|
||||||
|
Efekt: strona bez wypełnionego SEO title i tak pokaże swoją nazwę w karcie, nie
|
||||||
|
pusty tytuł ani sam siteName.
|
||||||
|
|
||||||
|
> **Uwaga — „Strona Główna” w tytule:** jeśli strona główna ma nazwę dokumentu
|
||||||
|
> „Strona Główna” (i auto-fill skopiował ją do meta.title), tytuł wyjdzie
|
||||||
|
> „Nazwa – Strona Główna” — bezużyteczne dla SEO. Napraw: wpisz **titleOverride**
|
||||||
|
> dla strony głównej (np. „Firma X – Usługa Miasto”), albo zmień meta.title na
|
||||||
|
> coś ze słowami kluczowymi. Fallback page.title nie pomoże, bo problemem jest
|
||||||
|
> sama treść nazwy, nie brak tytułu.
|
||||||
|
|
||||||
## Front — createPageMetadata
|
## Front — createPageMetadata
|
||||||
|
|
||||||
Dla zwykłych stron. Zna konwencje pluginu (kolekcja pages, SiteSettings, System
|
Dla zwykłych stron. Zna konwencje pluginu (kolekcja pages, SiteSettings, System
|
||||||
@@ -217,6 +242,7 @@ export const { /* ... */, sitemap, robots } = createContentHelpers({
|
|||||||
```ts
|
```ts
|
||||||
// app/sitemap.ts
|
// app/sitemap.ts
|
||||||
export { sitemap as default } from '@/lib/content'
|
export { sitemap as default } from '@/lib/content'
|
||||||
|
export const dynamic = 'force-dynamic' // generuj w runtime, nie w buildzie
|
||||||
|
|
||||||
// app/robots.ts
|
// app/robots.ts
|
||||||
export { robots as default } from '@/lib/content'
|
export { robots as default } from '@/lib/content'
|
||||||
@@ -227,6 +253,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
|
katalog projektu, nie node_modules. Ale re-eksport to maksimum redukcji: cała
|
||||||
logika jest w pluginie.
|
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:
|
Co zawiera sitemapa:
|
||||||
- każdą stronę i wpis bloga, URL w domyślnym locale
|
- każdą stronę i wpis bloga, URL w domyślnym locale
|
||||||
- `alternates.languages` → Next renderuje `<xhtml:link rel="alternate" hreflang>`
|
- `alternates.languages` → Next renderuje `<xhtml:link rel="alternate" hreflang>`
|
||||||
@@ -354,51 +390,88 @@ poprawnie: czytając z panelu/env, nie zaszywając wartości klienta.
|
|||||||
|
|
||||||
### Web App Manifest (PWA) — jak zrobić DOBRZE
|
### 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
|
```ts
|
||||||
// app/manifest.ts
|
// app/manifest.ts
|
||||||
import type { MetadataRoute } from 'next'
|
import type { MetadataRoute } from 'next'
|
||||||
import { getCachedPayload } from '@/lib/content'
|
import { getCachedPayload } from '@/lib/content'
|
||||||
import { getSiteSettings } from '@intecion/ipal-kit'
|
import { getSiteSettings } from '@intecion/ipal-kit'
|
||||||
|
import { i18nConfig } from '@/i18n.config'
|
||||||
import type { SiteSetting } from '@/payload-types'
|
import type { SiteSetting } from '@/payload-types'
|
||||||
|
|
||||||
export default async function manifest(): Promise<MetadataRoute.Manifest> {
|
export default async function manifest(): Promise<MetadataRoute.Manifest> {
|
||||||
const payload = await getCachedPayload()
|
const payload = await getCachedPayload()
|
||||||
const settings = await getSiteSettings<SiteSetting>(payload, { locale: 'pl' as never })
|
const settings = await getSiteSettings<SiteSetting>(payload, {
|
||||||
|
locale: i18nConfig.defaultLocale as never,
|
||||||
|
})
|
||||||
|
|
||||||
// Wszystko z panelu — zero hardkodu. Ikona z pola logo/favicon (upload),
|
const siteName = settings?.siteName?.trim()
|
||||||
// nie ze statycznej ścieżki.
|
|
||||||
const iconUrl =
|
|
||||||
typeof settings.logo === 'object' && settings.logo?.url ? settings.logo.url : undefined
|
|
||||||
|
|
||||||
|
// 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 {
|
return {
|
||||||
name: settings.siteName ?? '',
|
...(siteName ? { name: siteName, short_name: siteName } : {}),
|
||||||
short_name: settings.siteName ?? '', // albo osobne pole, jeśli dodasz
|
start_url: `/${i18nConfig.defaultLocale}`,
|
||||||
start_url: '/',
|
|
||||||
display: 'standalone',
|
display: 'standalone',
|
||||||
...(iconUrl
|
...(iconEntry ? { icons: [iconEntry] } : {}),
|
||||||
? { icons: [{ src: iconUrl, sizes: 'any', type: 'image/svg+xml' }] }
|
// theme_color / background_color / description — TYLKO jeśli dodasz pola w
|
||||||
: {}),
|
// panelu i je odczytasz. NIE zaszywaj '#0e1e24' ani opisu klienta.
|
||||||
// description / theme_color / background_color:
|
|
||||||
// jeśli klient ich potrzebuje, DODAJ POLA w SiteSettings i czytaj stąd —
|
|
||||||
// NIE wpisuj '#d4af37' na sztywno. Bez pól — pomiń (manifest działa bez nich).
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Kontrast — czego NIE robić** (realny błąd z sesji):
|
**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
|
```ts
|
||||||
// ŹLE — wszystko zaszyte, zadziała tylko dla jednego klienta
|
// ŹLE — hardkod jawny (rcustomcars)
|
||||||
let name = 'R Custom Cars' // hardkod nazwy
|
let name = 'R Custom Cars'; short_name: 'RCC'
|
||||||
short_name: 'RCC', // hardkod
|
background_color: '#08080a', theme_color: '#d4af37'
|
||||||
description: 'Custom car styling...', // hardkod
|
icons: [{ src: '/logo/rcc-logo.svg' }] // statyczna ścieżka
|
||||||
background_color: '#08080a', theme_color: '#d4af37', // hardkod kolorów
|
|
||||||
icons: [{ src: '/logo/rcc-logo.svg' }] // statyczna ścieżka, nie panel
|
// Ź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**
|
Jeśli klient potrzebuje kolorów motywu / opisu w manifeście — **dodaj pola**
|
||||||
`themeColor`, `manifestDescription` w SiteSettings (SiteSettingsFields przez
|
`themeColor`, `manifestDescription` w SiteSettings (przez opcje pluginu
|
||||||
opcje pluginu) i czytaj z panelu. Wtedy redaktor je zmienia, i nie są zaszyte.
|
SiteSettingsFields) i czytaj z panelu. Wtedy redaktor je zmienia, nie są zaszyte.
|
||||||
|
|
||||||
### Inne ręczne rozszerzenia — ta sama zasada
|
### Inne ręczne rozszerzenia — ta sama zasada
|
||||||
|
|
||||||
@@ -409,6 +482,104 @@ typu, itp.):
|
|||||||
- jeśli to uniwersalne i powtarzalne → rozważ zgłoszenie do pluginu zamiast
|
- jeśli to uniwersalne i powtarzalne → rozważ zgłoszenie do pluginu zamiast
|
||||||
ręcznie (patrz ANTIGRAVITY-ZASADY-AGENT.md A0)
|
ręcznie (patrz ANTIGRAVITY-ZASADY-AGENT.md A0)
|
||||||
|
|
||||||
|
## KRYTYCZNE: metadata w <head> dla Google
|
||||||
|
|
||||||
|
**Największa pułapka SEO w Next.js — dotyczy KAŻDEGO projektu.** Dla dynamicznie
|
||||||
|
renderowanych stron (SSR) Next.js **streamuje metadata do `<body>`**, nie `<head>`,
|
||||||
|
i przenosi ją do head skryptem JS. Skutek: canonical, hreflang, title, favicon
|
||||||
|
lądują w body w surowym HTML. Crawlery bez JS (Screaming Frog, część botów) widzą
|
||||||
|
je poza head → ignorują → utrata SEO.
|
||||||
|
|
||||||
|
**To wyścig czasowy (race condition):** gdy baza odpowie szybko, metadata zdąży
|
||||||
|
do head; gdy wolniej (albo crawler odpytuje wiele stron naraz, obciążając bazę),
|
||||||
|
Next zamyka `</head>` i dokleja metadata w `<body>`. Dlatego pojedynczy `curl`
|
||||||
|
może pokazać head OK, a test 10 zapytań — 5/10 w body. **Testuj wielokrotnie.**
|
||||||
|
|
||||||
|
### Rozwiązanie GŁÓWNE — ISR (revalidate) w stronach
|
||||||
|
|
||||||
|
Najskuteczniejsze: **cache całej strony (ISR)**. Strona generowana raz z gotowym
|
||||||
|
`<head>`, kolejne żądania serwują cache — zero zapytań do bazy przy renderowaniu,
|
||||||
|
więc race condition ZNIKA (metadata zawsze w head). Bonus: TTFB spada z ~500ms do
|
||||||
|
~20ms, znikają sporadyczne 503 (cold start).
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// app/(frontend)/[locale]/[[...slug]]/page.tsx
|
||||||
|
export const revalidate = 3600 // cache 1h, regeneracja w tle
|
||||||
|
```
|
||||||
|
|
||||||
|
> **UWAGA — ISR a treść z panelu:** strona cache'owana `revalidate` sekund NIE
|
||||||
|
> pokaże zmian redaktora od razu (czeka do rewalidacji). Dla treści zmienianej
|
||||||
|
> rzadko OK. Jeśli redaktor ma widzieć zmiany natychmiast — użyj **on-demand
|
||||||
|
> revalidation**: hook `afterChange` w kolekcji → `revalidatePath(path)` (patrz
|
||||||
|
> HOOKS.md). Albo krótszy `revalidate` (np. 300 = 5 min). NIE łącz ISR z
|
||||||
|
> `force-dynamic` — wykluczają się.
|
||||||
|
|
||||||
|
### Rozwiązanie DRUGIE — usuń jawny <head> z layoutu
|
||||||
|
|
||||||
|
Sztywny `<head>` w layoucie App Router wymusza przedwczesne zamknięcie head —
|
||||||
|
zanim strona wygeneruje metadane. To pcha metadata do body. NIE deklaruj `<head>`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ŹLE — jawny <head> zamyka head za wcześnie
|
||||||
|
<html lang={locale}>
|
||||||
|
<head><MediaPreconnect /></head>
|
||||||
|
<body>{children}</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
|
// DOBRZE — MediaPreconnect w body, React 19 hoistuje link do head
|
||||||
|
<html lang={locale}>
|
||||||
|
<body>
|
||||||
|
<MediaPreconnect />
|
||||||
|
{children}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
```
|
||||||
|
|
||||||
|
React 19 sam przenosi `<link rel="preconnect">` do head. Jawny `<head>` jest
|
||||||
|
zbędny i szkodliwy (wymusza wczesne zamknięcie).
|
||||||
|
|
||||||
|
### Rozwiązanie TRZECIE — htmlLimitedBots (uzupełnienie)
|
||||||
|
|
||||||
|
Dodatkowo można wymusić blocking metadata dla crawlerów (przydatne, gdy strona
|
||||||
|
z jakiegoś powodu nie może być ISR):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// next.config.ts
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
htmlLimitedBots:
|
||||||
|
/Googlebot|Google-InspectionTool|Storebot-Google|Bingbot|Yandex|DuckDuckBot|Baiduspider|Screaming Frog|AhrefsBot|SemrushBot/i,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`htmlLimitedBots` wyłącza streaming dla tych User-Agentów (metadata w head). ALE
|
||||||
|
to słabsze niż ISR — bo strona dalej renderuje dynamicznie (zapytanie do bazy →
|
||||||
|
wolniej, ryzyko race). **ISR eliminuje przyczynę, htmlLimitedBots łagodzi objaw.**
|
||||||
|
Najlepiej: ISR + brak jawnego head. htmlLimitedBots jako dodatkowa warstwa.
|
||||||
|
|
||||||
|
### Objawy (że masz ten problem)
|
||||||
|
|
||||||
|
- Screaming Frog: „canonical/hreflang/title outside <head>" (na wielu podstronach)
|
||||||
|
- Search Console: „brak canonical", favicon glob
|
||||||
|
- Surowy HTML: canonical/title PO `</head>`, na końcu body, ze skryptem appendChild
|
||||||
|
- Test 10 zapytań: część w head, część w body (race condition)
|
||||||
|
|
||||||
|
### Weryfikacja — TESTUJ WIELOKROTNIE (nie pojedynczo)
|
||||||
|
|
||||||
|
Pojedynczy `curl` może trafić w „szczęśliwy" timing. Testuj 10 razy:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 10 zapytań jako Googlebot — ile ma canonical w <head>
|
||||||
|
for i in $(seq 1 10); do
|
||||||
|
curl -s -A "Googlebot" https://twojadomena.pl/pl/strona | \
|
||||||
|
python3 -c "import sys; h=sys.stdin.read(); e=h.find('</head>'); c=h.find('rel=\"canonical\"'); print('head' if 0<c<e else 'BODY')"
|
||||||
|
done
|
||||||
|
# Cel: 10x 'head'. Jeśli część 'BODY' → race condition, dodaj ISR.
|
||||||
|
```
|
||||||
|
|
||||||
|
Testuj też PODSTRONY (nie tylko główną) — race częściej dotyka podstron.
|
||||||
|
DevTools (F12) NIE nadaje się do testu — hoistuje tagi do head automatycznie,
|
||||||
|
pokazując fałszywie poprawny head. Używaj `curl` / Ctrl+U (surowe źródło).
|
||||||
|
|
||||||
## SEO wielojęzyczne — hreflang, x-default, redirect roota
|
## SEO wielojęzyczne — hreflang, x-default, redirect roota
|
||||||
|
|
||||||
Przekierowanie `/` → `/pl` (negocjacja locale) może wpływać na SEO. Kluczowe:
|
Przekierowanie `/` → `/pl` (negocjacja locale) może wpływać na SEO. Kluczowe:
|
||||||
@@ -536,4 +707,181 @@ Plugin dostarcza helpery — projekt MUSI je wpiąć i podać dane z panelu:
|
|||||||
- [ ] Jasne, opisowe tytuły stron (nie generyczne)
|
- [ ] Jasne, opisowe tytuły stron (nie generyczne)
|
||||||
- [ ] Logiczna hierarchia + linkowanie wewnętrzne z głównej
|
- [ ] Logiczna hierarchia + linkowanie wewnętrzne z głównej
|
||||||
|
|
||||||
Dane WSZĘDZIE z panelu (siteName, nav, logo), nigdy zaszyte.
|
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 `<script type="application/ld+json">`.
|
||||||
|
|
||||||
|
## Wielojęzyczna strona główna — homeSlug per język (naprawione 1.2)
|
||||||
|
|
||||||
|
Gdy strona główna ma RÓŻNE slugi per język (pl: `strona-glowna`, de: `startseite`),
|
||||||
|
plugin obsługuje mapę homeSlug — każdy język zwija się do swojego roota (`/pl`,
|
||||||
|
`/de`), a hreflang wskazuje poprawnie (nie `/de/startseite`).
|
||||||
|
|
||||||
|
- **readSiteMetaConfig** czyta slug homepage per język (`locale: 'all'`) → mapa.
|
||||||
|
- **buildLocalizedPath** przyjmuje `homeSlug: string | Record<string, string>`.
|
||||||
|
- **hreflang** dostaje mapę → poprawne return tags (koniec błędu GSC „Missing
|
||||||
|
return tags”).
|
||||||
|
|
||||||
|
Działa automatycznie przez createPageMetadata. Warunek: strona główna musi mieć
|
||||||
|
slug wypełniony w KAŻDYM języku (panel, per locale).
|
||||||
|
|
||||||
|
## Sitemap wielojęzyczny — <url> per język (naprawione 1.2)
|
||||||
|
|
||||||
|
Sitemap emituje osobny `<url>` dla KAŻDEGO języka (nie tylko domyślnego). Zgodnie
|
||||||
|
z wymogiem Google: każda wersja językowa = osobny `<loc>` + alternates. GSC liczy
|
||||||
|
teraz wszystkie wersje (8×3 = 24, nie 8). Działa automatycznie w buildSitemapEntries.
|
||||||
|
|
||||||
|
## Aliasy strony głównej — 301 zamiast 200 (do wpięcia w projekcie)
|
||||||
|
|
||||||
|
PROBLEM: `/pl/strona-glowna` (pełny slug home) zwraca 200, tak jak `/pl` (root).
|
||||||
|
Google widzi duplikat („Duplikat bez URL kanonicznego”).
|
||||||
|
|
||||||
|
ROZWIĄZANIE (projekt): w page.tsx, jeśli slug odpowiada slugowi strony głównej
|
||||||
|
w danym języku, zrób redirect 301 na root:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// page.tsx — po resolveRoute
|
||||||
|
const homeSlugForLocale = /* slug home w tym języku, z settings */
|
||||||
|
if (slug?.length === 1 && slug[0] === homeSlugForLocale) {
|
||||||
|
redirect(`/${locale}`) // 301 do roota, nie serwuj duplikatu
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Plugin nie robi tego automatycznie (redirect to decyzja projektu — Next redirect
|
||||||
|
w page.tsx). Ale warto wpiąć, żeby nie mieć duplikatów w GSC. Alternatywnie:
|
||||||
|
canonical strony `/pl/strona-glowna` wskazujący na `/pl` (mniej czyste niż 301).
|
||||||
|
|
||||||
|
## Article/BlogPosting JSON-LD (blog)
|
||||||
|
|
||||||
|
Dla wpisów blogowych — buildArticleJsonLd generuje Article/BlogPosting rich
|
||||||
|
results (headline, data, autor, obraz):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { buildArticleJsonLd } from '@intecion/ipal-kit'
|
||||||
|
const jsonLd = buildArticleJsonLd({
|
||||||
|
headline: post.title, url: postUrl, description: post.excerpt,
|
||||||
|
image: post.coverImage, datePublished: post.publishedAt,
|
||||||
|
author: post.author, publisherName: settings.siteName,
|
||||||
|
publisherLogo: settings.logo, type: 'BlogPosting',
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Dane z dokumentu/panelu. Google wymaga headline + dat dla rich result.
|
||||||
|
|
||||||
|
## Fallback meta description (siteDescription)
|
||||||
|
|
||||||
|
Strona bez `meta.description` → plugin używa globalnego `siteDescription` z
|
||||||
|
SiteSettings. Lepsze niż brak description (Google losowo wyciąga tekst ze strony).
|
||||||
|
|
||||||
|
- Pole **siteDescription** w SiteSettings → General (localized, textarea)
|
||||||
|
- buildMetadata: `meta.description || siteDescription`
|
||||||
|
- Redaktor wypełnia raz globalnie; strony bez własnego opisu dziedziczą
|
||||||
|
|
||||||
|
## llms.txt — opis strony dla agentów AI (GEO)
|
||||||
|
|
||||||
|
`buildLlmsTxt` generuje `/llms.txt` (standard llmstxt.org) — plik Markdown
|
||||||
|
opisujący stronę dla agentów AI / crawlerów LLM. Na wzór buildRobots/sitemap:
|
||||||
|
plugin zna nazwę, opis i strony, więc generuje automatycznie.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// app/llms.txt/route.ts
|
||||||
|
import { buildLlmsTxt } from '@intecion/ipal-kit'
|
||||||
|
import { getCachedPayload } from '@/lib/content'
|
||||||
|
import { i18nConfig } from '@/i18n.config'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const body = await buildLlmsTxt({
|
||||||
|
payload: await getCachedPayload(),
|
||||||
|
config: i18nConfig,
|
||||||
|
baseUrl: process.env.NEXT_PUBLIC_SERVER_URL!,
|
||||||
|
})
|
||||||
|
return new Response(body, {
|
||||||
|
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Struktura: H1 (nazwa firmy), blockquote (siteDescription), lista stron jako
|
||||||
|
linki Markdown z opisami. Dane z panelu (siteName, siteDescription, strony),
|
||||||
|
pomija drafty i noindex. Poprawia widoczność w wyszukiwaniach AI (GEO —
|
||||||
|
Generative Engine Optimization) i audytach „Agentic Browsing”.
|
||||||
|
|
||||||
|
Wymaga wypełnionego siteDescription i sensownych meta.description stron
|
||||||
|
(inaczej llms.txt będzie ubogi).
|
||||||
@@ -133,6 +133,27 @@ pnpm dev
|
|||||||
# wgraj obraz w panelu (Media) → sprawdź w Cloudflare R2, czy plik się pojawił
|
# wgraj obraz w panelu (Media) → sprawdź w Cloudflare R2, czy plik się pojawił
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Root subdomeny media zwraca 404 (to normalne)
|
||||||
|
|
||||||
|
`media.klient.pl/plik.jpg` → R2 zwraca plik. Ale `media.klient.pl/` (sam root,
|
||||||
|
bez pliku) → **404**, bo R2 nie ma obiektu pod rootem. To NORMALNE zachowanie
|
||||||
|
R2, nie błąd.
|
||||||
|
|
||||||
|
Audyty SEO (Screaming Frog) czasem zgłaszają to 404 — bo crawler widzi URL-e
|
||||||
|
plików (`media.../logo.svg`) i próbuje roota. Ale:
|
||||||
|
- **NIE linkuj do samego roota** `media.klient.pl/` — tylko do plików. Kod nie
|
||||||
|
powinien nigdzie mieć `media.klient.pl/` bez nazwy pliku.
|
||||||
|
- Root media 404 **nie szkodzi SEO** głównej domeny (Google indeksuje klient.pl,
|
||||||
|
nie media.klient.pl). Nikt nie trafia na root media.
|
||||||
|
|
||||||
|
**Plugin tego nie naprawi** — subdomena media to serwis R2/Cloudflare, nie
|
||||||
|
aplikacja Next. Żądania do media.klient.pl nie docierają do Twojego kodu.
|
||||||
|
|
||||||
|
Jeśli chcesz „czysto" w Search Console (opcjonalne): Cloudflare → Rules →
|
||||||
|
Redirect Rules → gdy hostname = `media.klient.pl` i path = `/` → 301 na
|
||||||
|
`klient.pl`. Jednorazowo w panelu CF. Ale to kosmetyka — root media 404 jest
|
||||||
|
nieszkodliwe.
|
||||||
|
|
||||||
## Dev na lokalnym I na R2 (seedowanie podczas developmentu)
|
## Dev na lokalnym I na R2 (seedowanie podczas developmentu)
|
||||||
|
|
||||||
Fallback (brak zmiennych → lokalny dysk) oznacza, że **dev działa w obu trybach**:
|
Fallback (brak zmiennych → lokalny dysk) oznacza, że **dev działa w obu trybach**:
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@intecion/ipal-kit",
|
"name": "@intecion/ipal-kit",
|
||||||
"version": "1.1.2",
|
"version": "1.2.7",
|
||||||
"description": "Intecion Payload Advanced Library — a Payload CMS 3 plugin: i18n, SEO, forms, consent, analytics, blog/archives.",
|
"description": "Intecion Payload Advanced Library — a Payload CMS 3 plugin: i18n, SEO, forms, consent, analytics, blog/archives.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { validateFaviconField } from '../../../modules/seo/index.js'
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* General site identity fields.
|
* General site identity fields.
|
||||||
* Consumed by SEO/OG (siteName, defaultShareImage) and frontend (logo, favicon).
|
* Consumed by SEO/OG (siteName, defaultShareImage) and frontend (logo).
|
||||||
*/
|
*/
|
||||||
export const generalFields: Field[] = [
|
export const generalFields: Field[] = [
|
||||||
{
|
{
|
||||||
@@ -16,6 +16,15 @@ export const generalFields: Field[] = [
|
|||||||
localized: true,
|
localized: true,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'siteDescription',
|
||||||
|
type: 'textarea',
|
||||||
|
admin: {
|
||||||
|
description:
|
||||||
|
'Fallback meta description when a page has none. Also used for llms.txt and Open Graph.',
|
||||||
|
},
|
||||||
|
localized: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'titleOrder',
|
name: 'titleOrder',
|
||||||
type: 'select',
|
type: 'select',
|
||||||
@@ -47,8 +56,7 @@ export const generalFields: Field[] = [
|
|||||||
name: 'logo',
|
name: 'logo',
|
||||||
type: 'upload',
|
type: 'upload',
|
||||||
admin: {
|
admin: {
|
||||||
description:
|
description: 'Primary site logo.',
|
||||||
'Primary site logo. Also used for Organization structured data (buildOrganizationJsonLd).',
|
|
||||||
},
|
},
|
||||||
relationTo: 'media',
|
relationTo: 'media',
|
||||||
},
|
},
|
||||||
@@ -68,8 +76,7 @@ export const generalFields: Field[] = [
|
|||||||
'Square icon, PNG or SVG, min. 48×48px (Google requires this to show it in search). Rendered via buildIconsMetadata.',
|
'Square icon, PNG or SVG, min. 48×48px (Google requires this to show it in search). Rendered via buildIconsMetadata.',
|
||||||
},
|
},
|
||||||
hooks: {
|
hooks: {
|
||||||
// Warns the editor at save time if the favicon is too small (<48×48) or
|
// Warn the editor if the favicon is too small / not square for Google.
|
||||||
// not square — Google won't display such favicons in search results.
|
|
||||||
beforeValidate: [validateFaviconField],
|
beforeValidate: [validateFaviconField],
|
||||||
},
|
},
|
||||||
relationTo: 'media',
|
relationTo: 'media',
|
||||||
|
|||||||
@@ -100,8 +100,13 @@ export {
|
|||||||
export { buildSecurityHeaders } from './modules/security/index.js'
|
export { buildSecurityHeaders } from './modules/security/index.js'
|
||||||
export type { BuildSecurityHeadersArgs, SecurityHeader } from './modules/security/index.js'
|
export type { BuildSecurityHeadersArgs, SecurityHeader } from './modules/security/index.js'
|
||||||
export {
|
export {
|
||||||
|
buildArticleJsonLd,
|
||||||
|
buildFaqJsonLd,
|
||||||
buildIconsMetadata,
|
buildIconsMetadata,
|
||||||
|
buildLlmsTxt,
|
||||||
|
buildLocalBusinessJsonLd,
|
||||||
buildOrganizationJsonLd,
|
buildOrganizationJsonLd,
|
||||||
|
buildServiceJsonLd,
|
||||||
validateFaviconField,
|
validateFaviconField,
|
||||||
} from './modules/seo/index.js'
|
} from './modules/seo/index.js'
|
||||||
// Structured data (schema.org JSON-LD) — brand/sitelink signals for Google.
|
// Structured data (schema.org JSON-LD) — brand/sitelink signals for Google.
|
||||||
|
|||||||
@@ -1,22 +1,14 @@
|
|||||||
import type { BasePayload, SanitizedConfig } from 'payload'
|
|
||||||
|
|
||||||
import { getPayload } from 'payload'
|
|
||||||
import { cache } from 'react'
|
import { cache } from 'react'
|
||||||
|
import type { BasePayload, SanitizedConfig } from 'payload'
|
||||||
|
import { getPayload } from 'payload'
|
||||||
|
|
||||||
import type { ArchiveEntries, ContentOption, ResolvedRoute } from '../content/index.js'
|
import type { ContentOption, ResolvedRoute, ArchiveEntries } from '../content/index.js'
|
||||||
|
import { resolveRoute as resolveRouteRaw, getArchiveEntries } from '../content/index.js'
|
||||||
import type { I18nConfig } from '../i18n/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'
|
||||||
|
import { buildSitemapEntries, buildRobots } from '../seo/index.js'
|
||||||
import { getArchiveEntries, resolveRoute as resolveRouteRaw } from '../content/index.js'
|
|
||||||
import { buildRobots, buildSitemapEntries } from '../seo/index.js'
|
|
||||||
|
|
||||||
type CreateContentHelpersArgs = {
|
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).
|
* The client's payload config promise (the default export of payload.config).
|
||||||
* Passed in because the plugin never imports the client's config directly.
|
* Passed in because the plugin never imports the client's config directly.
|
||||||
@@ -24,15 +16,21 @@ type CreateContentHelpersArgs = {
|
|||||||
config: Promise<SanitizedConfig> | SanitizedConfig
|
config: Promise<SanitizedConfig> | SanitizedConfig
|
||||||
/** Archive-backed collections, same value as the plugin option. */
|
/** Archive-backed collections, same value as the plugin option. */
|
||||||
content?: ContentOption
|
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`
|
* i18n config. Required only if you want the ready-made `sitemap` / `robots`
|
||||||
* handlers — they need the locale list to emit hreflang.
|
* handlers — they need the locale list to emit hreflang.
|
||||||
*/
|
*/
|
||||||
i18n?: I18nConfig
|
i18n?: I18nConfig
|
||||||
/** Pages collection slug. Defaults to 'pages'. */
|
/**
|
||||||
pagesSlug?: string
|
* Absolute site origin for sitemap/robots URLs. Falls back to
|
||||||
/** SiteSettings global slug. Defaults to 'site-settings'. */
|
* NEXT_PUBLIC_SERVER_URL, then to a relative origin (which most crawlers
|
||||||
settingsSlug?: string
|
* reject, so set one in production).
|
||||||
|
*/
|
||||||
|
baseUrl?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -62,12 +60,12 @@ type CreateContentHelpersArgs = {
|
|||||||
* not the second, and a page component composes them in two obvious lines.
|
* not the second, and a page component composes them in two obvious lines.
|
||||||
*/
|
*/
|
||||||
export function createContentHelpers({
|
export function createContentHelpers({
|
||||||
baseUrl,
|
|
||||||
config,
|
config,
|
||||||
content,
|
content,
|
||||||
i18n,
|
|
||||||
pagesSlug = 'pages',
|
|
||||||
settingsSlug = 'site-settings',
|
settingsSlug = 'site-settings',
|
||||||
|
pagesSlug = 'pages',
|
||||||
|
i18n,
|
||||||
|
baseUrl,
|
||||||
}: CreateContentHelpersArgs) {
|
}: CreateContentHelpersArgs) {
|
||||||
const origin = baseUrl ?? process.env.NEXT_PUBLIC_SERVER_URL ?? ''
|
const origin = baseUrl ?? process.env.NEXT_PUBLIC_SERVER_URL ?? ''
|
||||||
const getCachedPayload = cache(async (): Promise<BasePayload> =>
|
const getCachedPayload = cache(async (): Promise<BasePayload> =>
|
||||||
@@ -81,7 +79,7 @@ export function createContentHelpers({
|
|||||||
|
|
||||||
const getSettings = cache(async (locale: string) => {
|
const getSettings = cache(async (locale: string) => {
|
||||||
const payload = await getCachedPayload()
|
const payload = await getCachedPayload()
|
||||||
return payload.findGlobal({ slug: settingsSlug as never, depth: 2, locale: locale as never })
|
return payload.findGlobal({ slug: settingsSlug as never, locale: locale as never, depth: 2 })
|
||||||
})
|
})
|
||||||
|
|
||||||
/** What does this URL point at? Routing only — no listing data. */
|
/** What does this URL point at? Routing only — no listing data. */
|
||||||
@@ -90,9 +88,9 @@ export function createContentHelpers({
|
|||||||
locale: string,
|
locale: string,
|
||||||
segments: string[] | undefined,
|
segments: string[] | undefined,
|
||||||
page: number,
|
page: number,
|
||||||
): Promise<null | ResolvedRoute> => {
|
): Promise<ResolvedRoute | null> => {
|
||||||
const payload = await getCachedPayload()
|
const payload = await getCachedPayload()
|
||||||
return resolveRouteRaw({ content, locale, page, pagesSlug, payload, segments, settingsSlug })
|
return resolveRouteRaw({ payload, locale, segments, page, content, pagesSlug, settingsSlug })
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -105,7 +103,7 @@ export function createContentHelpers({
|
|||||||
perPage: number,
|
perPage: number,
|
||||||
): Promise<ArchiveEntries> => {
|
): Promise<ArchiveEntries> => {
|
||||||
const payload = await getCachedPayload()
|
const payload = await getCachedPayload()
|
||||||
return getArchiveEntries({ collection, locale, page, payload, perPage })
|
return getArchiveEntries({ payload, collection, locale, page, perPage })
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -116,20 +114,45 @@ export function createContentHelpers({
|
|||||||
* ```ts
|
* ```ts
|
||||||
* // app/sitemap.ts
|
* // app/sitemap.ts
|
||||||
* export { sitemap as default } from '@/lib/content'
|
* 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 (): Promise<SitemapEntry[]> => {
|
const sitemap = cache(async (): Promise<SitemapEntry[]> => {
|
||||||
if (!i18n) {
|
if (!i18n) {
|
||||||
throw new Error('[ipal] createContentHelpers: pass `i18n` to use the sitemap handler.')
|
throw new Error('[ipal] createContentHelpers: pass `i18n` to use the sitemap handler.')
|
||||||
}
|
}
|
||||||
return buildSitemapEntries({
|
try {
|
||||||
baseUrl: origin,
|
return await buildSitemapEntries({
|
||||||
config: i18n,
|
payload: await getCachedPayload(),
|
||||||
content,
|
config: i18n,
|
||||||
pagesSlug,
|
baseUrl: origin,
|
||||||
payload: await getCachedPayload(),
|
content,
|
||||||
settingsSlug,
|
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 []
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -145,10 +168,10 @@ export function createContentHelpers({
|
|||||||
return {
|
return {
|
||||||
getCachedPayload,
|
getCachedPayload,
|
||||||
getConfiguredLocales,
|
getConfiguredLocales,
|
||||||
getEntries,
|
|
||||||
getSettings,
|
getSettings,
|
||||||
resolveRoute,
|
resolveRoute,
|
||||||
robots,
|
getEntries,
|
||||||
sitemap,
|
sitemap,
|
||||||
|
robots,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
import type { I18nConfig } from './types.js'
|
import type { I18nConfig } from '../i18n/index.js'
|
||||||
|
import { negotiateLocale, isValidLocale, LOCALE_COOKIE_NAME } from '../i18n/index.js'
|
||||||
import { CONSENT_COOKIE, parseConsent } from '../consent/storage.js'
|
import { CONSENT_COOKIE, parseConsent } from '../consent/storage.js'
|
||||||
import { isValidLocale, LOCALE_COOKIE_NAME, negotiateLocale } from '../i18n/index.js'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Minimal request shape the middleware reads. Kept structural so the plugin
|
* Minimal request shape the middleware reads. Kept structural so the plugin
|
||||||
* doesn't hard-depend on next/server types; a Next.js `NextRequest` satisfies it.
|
* doesn't hard-depend on next/server types; a Next.js `NextRequest` satisfies it.
|
||||||
*/
|
*/
|
||||||
type MiddlewareRequest = {
|
type MiddlewareRequest = {
|
||||||
|
nextUrl: { pathname: string; search: string; clone: () => URL }
|
||||||
cookies: { get: (name: string) => { value: string } | undefined }
|
cookies: { get: (name: string) => { value: string } | undefined }
|
||||||
headers: { get: (name: string) => null | string }
|
headers: { get: (name: string) => string | null }
|
||||||
nextUrl: { clone: () => URL; pathname: string; search: string }
|
|
||||||
url: string
|
url: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,11 +19,13 @@ type MiddlewareRequest = {
|
|||||||
* `next` means let the request pass through untouched.
|
* `next` means let the request pass through untouched.
|
||||||
*/
|
*/
|
||||||
export type LocaleMiddlewareResult =
|
export type LocaleMiddlewareResult =
|
||||||
| { cookie?: { name: string; value: string }; location: string; type: 'redirect' }
|
| { type: 'next'; cookie?: { name: string; value: string } }
|
||||||
| { cookie?: { name: string; value: string }; type: 'next' }
|
| { type: 'redirect'; location: string; cookie?: { name: string; value: string } }
|
||||||
|
|
||||||
type CreateLocaleMiddlewareArgs = {
|
type CreateLocaleMiddlewareArgs = {
|
||||||
config: I18nConfig
|
config: I18nConfig
|
||||||
|
/** Cookie name for the locale choice. Defaults to LOCALE_COOKIE_NAME. */
|
||||||
|
cookieName?: string
|
||||||
/**
|
/**
|
||||||
* Consent category that gates *persisting* the locale cookie. The locale is
|
* Consent category that gates *persisting* the locale cookie. The locale is
|
||||||
* always detected (routing works regardless), but the choice is only written
|
* always detected (routing works regardless), but the choice is only written
|
||||||
@@ -32,11 +33,9 @@ type CreateLocaleMiddlewareArgs = {
|
|||||||
* 'functional'. Pass 'necessary' to always persist (treat locale as strictly
|
* 'functional'. Pass 'necessary' to always persist (treat locale as strictly
|
||||||
* necessary), which restores the pre-consent behaviour.
|
* necessary), which restores the pre-consent behaviour.
|
||||||
*/
|
*/
|
||||||
consentCategory?: 'functional' | 'necessary'
|
consentCategory?: 'necessary' | 'functional'
|
||||||
/** Name of the consent cookie to read. Defaults to CONSENT_COOKIE. */
|
/** Name of the consent cookie to read. Defaults to CONSENT_COOKIE. */
|
||||||
consentCookieName?: string
|
consentCookieName?: string
|
||||||
/** Cookie name for the locale choice. Defaults to LOCALE_COOKIE_NAME. */
|
|
||||||
cookieName?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -76,14 +75,14 @@ function firstSegment(pathname: string): string {
|
|||||||
*/
|
*/
|
||||||
export function createLocaleMiddleware({
|
export function createLocaleMiddleware({
|
||||||
config,
|
config,
|
||||||
|
cookieName = LOCALE_COOKIE_NAME,
|
||||||
consentCategory = 'functional',
|
consentCategory = 'functional',
|
||||||
consentCookieName = CONSENT_COOKIE,
|
consentCookieName = CONSENT_COOKIE,
|
||||||
cookieName = LOCALE_COOKIE_NAME,
|
|
||||||
}: CreateLocaleMiddlewareArgs) {
|
}: CreateLocaleMiddlewareArgs) {
|
||||||
// Whether the locale cookie may be written: 'necessary' is always granted;
|
// Whether the locale cookie may be written: 'necessary' is always granted;
|
||||||
// 'functional' (default) requires the visitor to have consented.
|
// 'functional' (default) requires the visitor to have consented.
|
||||||
function mayPersistLocale(request: MiddlewareRequest): boolean {
|
function mayPersistLocale(request: MiddlewareRequest): boolean {
|
||||||
if (consentCategory === 'necessary') {return true}
|
if (consentCategory === 'necessary') return true
|
||||||
const consent = parseConsent(request.cookies.get(consentCookieName)?.value)
|
const consent = parseConsent(request.cookies.get(consentCookieName)?.value)
|
||||||
return consent?.[consentCategory] === true
|
return consent?.[consentCategory] === true
|
||||||
}
|
}
|
||||||
@@ -91,6 +90,15 @@ export function createLocaleMiddleware({
|
|||||||
return function localeMiddleware(request: MiddlewareRequest): LocaleMiddlewareResult {
|
return function localeMiddleware(request: MiddlewareRequest): LocaleMiddlewareResult {
|
||||||
const { pathname } = request.nextUrl
|
const { pathname } = request.nextUrl
|
||||||
|
|
||||||
|
// Single-locale sites have no /pl, /en prefix and no negotiation — one
|
||||||
|
// language, no redirect. The middleware becomes a pass-through: paths stay
|
||||||
|
// as-is (/o-nas), nothing to detect or persist. (Projects that are truly
|
||||||
|
// single-locale usually don't even mount this middleware, but guarding here
|
||||||
|
// makes it safe if they do.)
|
||||||
|
if (config.locales.length === 1) {
|
||||||
|
return { type: 'next' }
|
||||||
|
}
|
||||||
|
|
||||||
// Already locale-prefixed (e.g. the visitor switched language by
|
// Already locale-prefixed (e.g. the visitor switched language by
|
||||||
// navigating to /en). Routing is fine — but if the URL's locale differs
|
// navigating to /en). Routing is fine — but if the URL's locale differs
|
||||||
// from the stored cookie, the visitor is *choosing* a language, and we
|
// from the stored cookie, the visitor is *choosing* a language, and we
|
||||||
@@ -108,9 +116,9 @@ export function createLocaleMiddleware({
|
|||||||
|
|
||||||
// Resolve the locale to use
|
// Resolve the locale to use
|
||||||
const locale = negotiateLocale({
|
const locale = negotiateLocale({
|
||||||
|
cookieLocale: request.cookies.get(cookieName)?.value ?? null,
|
||||||
acceptLanguage: request.headers.get('accept-language'),
|
acceptLanguage: request.headers.get('accept-language'),
|
||||||
config,
|
config,
|
||||||
cookieLocale: request.cookies.get(cookieName)?.value ?? null,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Redirect to the locale-prefixed path, preserving the rest
|
// Redirect to the locale-prefixed path, preserving the rest
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { I18nConfig } from './types.js'
|
import type { I18nConfig } from './types.js'
|
||||||
|
|
||||||
import { isValidLocale } from './helpers.js'
|
import { isValidLocale } from './helpers.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -12,14 +11,22 @@ import { isValidLocale } from './helpers.js'
|
|||||||
export type LocalizedSlugs = Record<string, string>
|
export type LocalizedSlugs = Record<string, string>
|
||||||
|
|
||||||
type BuildPathArgs = {
|
type BuildPathArgs = {
|
||||||
config: I18nConfig
|
/** slug per locale, e.g. { pl: 'strona-glowna', en: 'home' } */
|
||||||
/**
|
slugs: LocalizedSlugs
|
||||||
* Slug that represents the site root (served at /{locale} with no trailing
|
|
||||||
* segment). Defaults to 'home'. Matched against the slug in the target locale.
|
|
||||||
*/
|
|
||||||
homeSlug?: string
|
|
||||||
/** Target locale to build the path for */
|
/** Target locale to build the path for */
|
||||||
locale: string
|
locale: string
|
||||||
|
config: I18nConfig
|
||||||
|
/**
|
||||||
|
* Slug(s) representing the site root (served at /{locale} with no trailing
|
||||||
|
* segment). Defaults to 'home'. Matched against the slug in the target locale.
|
||||||
|
*
|
||||||
|
* Can be a single string (same home slug in every locale) OR a per-locale map
|
||||||
|
* (`{ pl: 'strona-glowna', de: 'startseite', en: 'home' }`). The map form is
|
||||||
|
* REQUIRED for multilingual homepages whose slug differs per language —
|
||||||
|
* otherwise the home page collapses to '/pl' but '/de/startseite' stays
|
||||||
|
* un-collapsed, breaking hreflang return tags (a real GSC error).
|
||||||
|
*/
|
||||||
|
homeSlug?: string | Record<string, string>
|
||||||
/**
|
/**
|
||||||
* Localized segment the document lives under, e.g.
|
* Localized segment the document lives under, e.g.
|
||||||
* `{ pl: 'artykuly', en: 'articles' }` → /pl/artykuly/moj-post.
|
* `{ pl: 'artykuly', en: 'articles' }` → /pl/artykuly/moj-post.
|
||||||
@@ -29,8 +36,6 @@ type BuildPathArgs = {
|
|||||||
* A document under a prefix is never the home page, so homeSlug is ignored.
|
* A document under a prefix is never the home page, so homeSlug is ignored.
|
||||||
*/
|
*/
|
||||||
prefix?: LocalizedSlugs
|
prefix?: LocalizedSlugs
|
||||||
/** slug per locale, e.g. { pl: 'strona-glowna', en: 'home' } */
|
|
||||||
slugs: LocalizedSlugs
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -56,11 +61,11 @@ type BuildPathArgs = {
|
|||||||
* // → '/en/articles/my-post'
|
* // → '/en/articles/my-post'
|
||||||
*/
|
*/
|
||||||
export function buildLocalizedPath({
|
export function buildLocalizedPath({
|
||||||
|
slugs,
|
||||||
|
locale,
|
||||||
config,
|
config,
|
||||||
homeSlug = 'home',
|
homeSlug = 'home',
|
||||||
locale,
|
|
||||||
prefix,
|
prefix,
|
||||||
slugs,
|
|
||||||
}: BuildPathArgs): string | undefined {
|
}: BuildPathArgs): string | undefined {
|
||||||
if (!isValidLocale(locale, config)) {
|
if (!isValidLocale(locale, config)) {
|
||||||
return undefined
|
return undefined
|
||||||
@@ -71,6 +76,13 @@ export function buildLocalizedPath({
|
|||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Single-locale sites have no /pl, /en prefix — the language segment is
|
||||||
|
// dropped entirely (path is /o-nas, not /pl/o-nas). Detected automatically:
|
||||||
|
// one configured locale means one language, so no prefix is needed. The
|
||||||
|
// project's folder structure matches (app/[[...slug]] without [locale]).
|
||||||
|
const singleLocale = config.locales.length === 1
|
||||||
|
const localeSegment = singleLocale ? '' : `/${locale}`
|
||||||
|
|
||||||
if (prefix) {
|
if (prefix) {
|
||||||
const segment = prefix[locale]
|
const segment = prefix[locale]
|
||||||
// No archive slug in this locale means the entry is unreachable there —
|
// No archive slug in this locale means the entry is unreachable there —
|
||||||
@@ -79,22 +91,29 @@ export function buildLocalizedPath({
|
|||||||
if (!segment) {
|
if (!segment) {
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
return `/${locale}/${segment}/${slug}`
|
return `${localeSegment}/${segment}/${slug}`
|
||||||
}
|
}
|
||||||
|
|
||||||
if (slug === homeSlug) {
|
// Resolve the home slug for THIS locale. With a per-locale map, each language
|
||||||
return `/${locale}`
|
// has its own home slug (pl: 'strona-glowna', de: 'startseite'), so the check
|
||||||
|
// below correctly collapses each language's home to its root — instead of only
|
||||||
|
// the current-language slug matching and the others staying un-collapsed.
|
||||||
|
const targetHomeSlug = typeof homeSlug === 'object' ? homeSlug[locale] : homeSlug
|
||||||
|
|
||||||
|
if (slug === targetHomeSlug) {
|
||||||
|
// Home collapses to the root: '/' for single-locale, '/pl' otherwise.
|
||||||
|
return localeSegment || '/'
|
||||||
}
|
}
|
||||||
|
|
||||||
return `/${locale}/${slug}`
|
return `${localeSegment}/${slug}`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SwitchLocaleArgs = {
|
type SwitchLocaleArgs = {
|
||||||
config: I18nConfig
|
|
||||||
homeSlug?: string
|
|
||||||
prefix?: LocalizedSlugs
|
|
||||||
slugs: LocalizedSlugs
|
slugs: LocalizedSlugs
|
||||||
targetLocale: string
|
targetLocale: string
|
||||||
|
config: I18nConfig
|
||||||
|
homeSlug?: string | Record<string, string>
|
||||||
|
prefix?: LocalizedSlugs
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -106,17 +125,17 @@ type SwitchLocaleArgs = {
|
|||||||
* to the locale root otherwise — the closest place the visitor would want.
|
* to the locale root otherwise — the closest place the visitor would want.
|
||||||
*/
|
*/
|
||||||
export function switchLocalePath({
|
export function switchLocalePath({
|
||||||
|
slugs,
|
||||||
|
targetLocale,
|
||||||
config,
|
config,
|
||||||
homeSlug = 'home',
|
homeSlug = 'home',
|
||||||
prefix,
|
prefix,
|
||||||
slugs,
|
|
||||||
targetLocale,
|
|
||||||
}: SwitchLocaleArgs): string {
|
}: SwitchLocaleArgs): string {
|
||||||
const path = buildLocalizedPath({ config, homeSlug, locale: targetLocale, prefix, slugs })
|
const path = buildLocalizedPath({ slugs, locale: targetLocale, config, homeSlug, prefix })
|
||||||
if (path) {return path}
|
if (path) return path
|
||||||
|
|
||||||
const archiveSegment = prefix?.[targetLocale]
|
const archiveSegment = prefix?.[targetLocale]
|
||||||
if (archiveSegment) {return `/${targetLocale}/${archiveSegment}`}
|
if (archiveSegment) return `/${targetLocale}/${archiveSegment}`
|
||||||
|
|
||||||
return `/${targetLocale}`
|
return `/${targetLocale}`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ type GetSystemPagePathArgs = {
|
|||||||
* page's slug in the target locale equals this, the path collapses to the
|
* page's slug in the target locale equals this, the path collapses to the
|
||||||
* locale root (/pl, /en).
|
* locale root (/pl, /en).
|
||||||
*/
|
*/
|
||||||
homeSlug?: string
|
homeSlug?: string | Record<string, string>
|
||||||
/** Target locale to build the path for. */
|
/** Target locale to build the path for. */
|
||||||
locale: string
|
locale: string
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
type MediaLike = { url?: string | null } | null | undefined
|
||||||
|
|
||||||
|
type ArticleJsonLdArgs = {
|
||||||
|
/** Article headline (the title). */
|
||||||
|
headline: string
|
||||||
|
/** Absolute URL of the article. */
|
||||||
|
url: string
|
||||||
|
description?: string
|
||||||
|
/** Cover/hero image. */
|
||||||
|
image?: MediaLike
|
||||||
|
/** ISO date the article was first published, e.g. '2026-01-15'. */
|
||||||
|
datePublished?: string
|
||||||
|
/** ISO date of the last modification. Defaults to datePublished. */
|
||||||
|
dateModified?: string
|
||||||
|
/** Author name (person or org). */
|
||||||
|
author?: string
|
||||||
|
/** Publisher (usually the site/organization) name. */
|
||||||
|
publisherName?: string
|
||||||
|
/** Publisher logo (for the Article rich result). */
|
||||||
|
publisherLogo?: MediaLike
|
||||||
|
/** 'Article' (default), 'BlogPosting', or 'NewsArticle'. */
|
||||||
|
type?: 'Article' | 'BlogPosting' | 'NewsArticle'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds Article / BlogPosting JSON-LD (schema.org) for blog posts and articles.
|
||||||
|
* Enables article rich results in Google (headline, date, author, image). Pairs
|
||||||
|
* with the plugin's content collections (blog/archives).
|
||||||
|
*
|
||||||
|
* Per-article (each post emits its own), data from the document + panel:
|
||||||
|
*
|
||||||
|
* import { buildArticleJsonLd } from '@intecion/ipal-kit'
|
||||||
|
* const jsonLd = buildArticleJsonLd({
|
||||||
|
* headline: post.title, url: postUrl, description: post.excerpt,
|
||||||
|
* image: post.coverImage, datePublished: post.publishedAt,
|
||||||
|
* author: post.author, publisherName: settings.siteName,
|
||||||
|
* publisherLogo: settings.logo, type: 'BlogPosting',
|
||||||
|
* })
|
||||||
|
*
|
||||||
|
* All data from the document/panel — nothing hardcoded. Google requires headline
|
||||||
|
* and dates for the rich result; supply them from the post's real fields.
|
||||||
|
*/
|
||||||
|
export function buildArticleJsonLd({
|
||||||
|
headline,
|
||||||
|
url,
|
||||||
|
description,
|
||||||
|
image,
|
||||||
|
datePublished,
|
||||||
|
dateModified,
|
||||||
|
author,
|
||||||
|
publisherName,
|
||||||
|
publisherLogo,
|
||||||
|
type = 'Article',
|
||||||
|
}: ArticleJsonLdArgs) {
|
||||||
|
const imageUrl = image?.url
|
||||||
|
const logoUrl = publisherLogo?.url
|
||||||
|
|
||||||
|
const abs = (u: string | null | undefined): string | undefined =>
|
||||||
|
u ? (u.startsWith('http') ? u : `${url.split('/').slice(0, 3).join('/')}${u}`) : undefined
|
||||||
|
|
||||||
|
return {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': type,
|
||||||
|
headline,
|
||||||
|
url,
|
||||||
|
mainEntityOfPage: { '@type': 'WebPage', '@id': url },
|
||||||
|
...(description ? { description } : {}),
|
||||||
|
...(imageUrl ? { image: abs(imageUrl) } : {}),
|
||||||
|
...(datePublished ? { datePublished } : {}),
|
||||||
|
...(dateModified || datePublished ? { dateModified: dateModified ?? datePublished } : {}),
|
||||||
|
...(author ? { author: { '@type': 'Person', name: author } } : {}),
|
||||||
|
...(publisherName
|
||||||
|
? {
|
||||||
|
publisher: {
|
||||||
|
'@type': 'Organization',
|
||||||
|
name: publisherName,
|
||||||
|
...(logoUrl ? { logo: { '@type': 'ImageObject', url: abs(logoUrl) } } : {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
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 function buildFaqJsonLd(items: FaqItem[]) {
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import type { BasePayload } from 'payload'
|
||||||
|
|
||||||
|
import type { I18nConfig } from '../i18n/index.js'
|
||||||
|
|
||||||
|
import { buildLocalizedPath } from '../i18n/index.js'
|
||||||
|
|
||||||
|
type BuildLlmsTxtArgs = {
|
||||||
|
/** Absolute site URL (https://…). */
|
||||||
|
baseUrl: string
|
||||||
|
config: I18nConfig
|
||||||
|
/** Locale for names/descriptions. Defaults to config.defaultLocale. */
|
||||||
|
locale?: string
|
||||||
|
/** Pages collection slug. Defaults to 'pages'. */
|
||||||
|
pagesSlug?: string
|
||||||
|
payload: BasePayload
|
||||||
|
/** Site settings global slug. Defaults to 'site-settings'. */
|
||||||
|
settingsSlug?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type SettingsShape = {
|
||||||
|
siteDescription?: null | string
|
||||||
|
siteName?: null | string
|
||||||
|
}
|
||||||
|
|
||||||
|
type PageRow = {
|
||||||
|
_status?: string
|
||||||
|
id: number | string
|
||||||
|
meta?: { description?: unknown; noindex?: boolean } | null
|
||||||
|
slug?: unknown
|
||||||
|
title?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the body of /llms.txt — a Markdown file describing the site for AI
|
||||||
|
* agents / LLM crawlers, per the llmstxt.org convention. Mirrors buildRobots /
|
||||||
|
* buildSitemapEntries: the plugin already knows the site's name, description,
|
||||||
|
* and pages, so it can generate this automatically.
|
||||||
|
*
|
||||||
|
* Structure (llmstxt.org): H1 site name, a blockquote/summary, then a list of
|
||||||
|
* key pages as Markdown links with short descriptions. Agents read this to
|
||||||
|
* understand the site quickly without crawling everything.
|
||||||
|
*
|
||||||
|
* Wire it as a route that returns text/plain:
|
||||||
|
*
|
||||||
|
* // app/llms.txt/route.ts
|
||||||
|
* import { buildLlmsTxt } from '@intecion/ipal-kit'
|
||||||
|
* import { getCachedPayload } from '@/lib/content'
|
||||||
|
* import { i18nConfig } from '@/i18n.config'
|
||||||
|
* export const dynamic = 'force-dynamic'
|
||||||
|
* export async function GET() {
|
||||||
|
* const body = await buildLlmsTxt({
|
||||||
|
* payload: await getCachedPayload(),
|
||||||
|
* config: i18nConfig,
|
||||||
|
* baseUrl: process.env.NEXT_PUBLIC_SERVER_URL!,
|
||||||
|
* })
|
||||||
|
* return new Response(body, { headers: { 'Content-Type': 'text/plain; charset=utf-8' } })
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* Data comes from the panel (siteName, siteDescription, pages) — nothing
|
||||||
|
* hardcoded. Skips drafts and noindex pages (same as the sitemap).
|
||||||
|
*/
|
||||||
|
export async function buildLlmsTxt({
|
||||||
|
baseUrl,
|
||||||
|
config,
|
||||||
|
locale,
|
||||||
|
pagesSlug = 'pages',
|
||||||
|
payload,
|
||||||
|
settingsSlug = 'site-settings',
|
||||||
|
}: BuildLlmsTxtArgs): Promise<string> {
|
||||||
|
const loc = locale ?? config.defaultLocale
|
||||||
|
const origin = baseUrl.replace(/\/$/, '')
|
||||||
|
|
||||||
|
const settings = (await payload.findGlobal({
|
||||||
|
slug: settingsSlug as never,
|
||||||
|
depth: 0,
|
||||||
|
locale: loc as never,
|
||||||
|
})) as SettingsShape
|
||||||
|
|
||||||
|
const name = settings.siteName?.trim() || 'Website'
|
||||||
|
const description = settings.siteDescription?.trim()
|
||||||
|
|
||||||
|
const result = await payload.find({
|
||||||
|
collection: pagesSlug as never,
|
||||||
|
depth: 0,
|
||||||
|
limit: 1000,
|
||||||
|
locale: loc as never,
|
||||||
|
where: { _status: { not_equals: 'draft' } } as never,
|
||||||
|
})
|
||||||
|
|
||||||
|
const lines: string[] = [`# ${name}`, '']
|
||||||
|
if (description) {
|
||||||
|
lines.push(`> ${description}`, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageLinks: string[] = []
|
||||||
|
for (const raw of result.docs as PageRow[]) {
|
||||||
|
if (raw._status && raw._status !== 'published') {continue}
|
||||||
|
if (raw.meta?.noindex) {continue}
|
||||||
|
|
||||||
|
const title = typeof raw.title === 'string' ? raw.title : undefined
|
||||||
|
const slug = typeof raw.slug === 'string' ? raw.slug : undefined
|
||||||
|
if (!title || !slug) {continue}
|
||||||
|
|
||||||
|
const path = buildLocalizedPath({ config, locale: loc, slugs: { [loc]: slug } })
|
||||||
|
if (!path) {continue}
|
||||||
|
|
||||||
|
const pageDesc = typeof raw.meta?.description === 'string' ? ` — ${raw.meta.description}` : ''
|
||||||
|
pageLinks.push(`- [${title}](${origin}${path})${pageDesc}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pageLinks.length > 0) {
|
||||||
|
lines.push('## Strony', '', ...pageLinks, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n')
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
type MediaLike = { url?: null | string } | null | undefined
|
||||||
|
|
||||||
|
type Address = {
|
||||||
|
city?: string
|
||||||
|
country?: string // ISO code, e.g. 'PL'
|
||||||
|
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 // e.g. '$$'
|
||||||
|
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 function buildLocalBusinessJsonLd({
|
||||||
|
name,
|
||||||
|
address,
|
||||||
|
geo,
|
||||||
|
image,
|
||||||
|
logo,
|
||||||
|
openingHours,
|
||||||
|
priceRange,
|
||||||
|
sameAs,
|
||||||
|
telephone,
|
||||||
|
url,
|
||||||
|
}: LocalBusinessJsonLdArgs) {
|
||||||
|
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 } : {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,11 @@ export type PageMetadata = {
|
|||||||
locale?: string
|
locale?: string
|
||||||
title: string
|
title: string
|
||||||
}
|
}
|
||||||
|
/** robots directives — set to noindex/follow for legal/thin/search pages. */
|
||||||
|
robots?: {
|
||||||
|
follow: boolean
|
||||||
|
index: boolean
|
||||||
|
}
|
||||||
title: string
|
title: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,7 +35,7 @@ type BuildMetadataArgs = {
|
|||||||
baseUrl?: string
|
baseUrl?: string
|
||||||
config: I18nConfig
|
config: I18nConfig
|
||||||
/** Home slug that collapses to the locale root. Defaults to 'home'. */
|
/** Home slug that collapses to the locale root. Defaults to 'home'. */
|
||||||
homeSlug?: string
|
homeSlug?: Record<string, string> | string
|
||||||
/** Resolved OG image URL (page image or site defaultShareImage). */
|
/** Resolved OG image URL (page image or site defaultShareImage). */
|
||||||
imageUrl?: null | string
|
imageUrl?: null | string
|
||||||
/** Current locale being rendered. */
|
/** Current locale being rendered. */
|
||||||
@@ -39,6 +44,13 @@ type BuildMetadataArgs = {
|
|||||||
meta?: null | SeoMeta
|
meta?: null | SeoMeta
|
||||||
/** Page title or site name first. Defaults to 'page-first'. */
|
/** Page title or site name first. Defaults to 'page-first'. */
|
||||||
order?: TitleOrder
|
order?: TitleOrder
|
||||||
|
/**
|
||||||
|
* The document's own title (e.g. page.title = 'Sprzątanie biur'). Used as the
|
||||||
|
* page-title source when meta.title is empty — the browser tab and search
|
||||||
|
* result should show the page name, not go blank, when an editor didn't fill
|
||||||
|
* the SEO title. Priority: titleOverride > meta.title > pageTitle.
|
||||||
|
*/
|
||||||
|
pageTitle?: null | string
|
||||||
/**
|
/**
|
||||||
* Localized segment the document lives under (an archive page's slugs).
|
* Localized segment the document lives under (an archive page's slugs).
|
||||||
* Feeds both canonical and hreflang, so /pl/artykuly/moj-post and
|
* Feeds both canonical and hreflang, so /pl/artykuly/moj-post and
|
||||||
@@ -55,6 +67,12 @@ type BuildMetadataArgs = {
|
|||||||
query?: string
|
query?: string
|
||||||
/** Separator between page title and site name. Defaults to ' | '. */
|
/** Separator between page title and site name. Defaults to ' | '. */
|
||||||
separator?: string
|
separator?: string
|
||||||
|
/**
|
||||||
|
* Global fallback description (from SiteSettings.siteDescription). Used when a
|
||||||
|
* page has no meta.description — better than emitting no description at all
|
||||||
|
* (Google would then scrape random on-page text for the snippet).
|
||||||
|
*/
|
||||||
|
siteDescription?: null | string
|
||||||
/** Site name for title composition and OG. */
|
/** Site name for title composition and OG. */
|
||||||
siteName?: null | string
|
siteName?: null | string
|
||||||
/** slug per locale for this document — drives canonical + hreflang. */
|
/** slug per locale for this document — drives canonical + hreflang. */
|
||||||
@@ -78,17 +96,23 @@ export function buildMetadata({
|
|||||||
locale,
|
locale,
|
||||||
meta,
|
meta,
|
||||||
order,
|
order,
|
||||||
|
pageTitle,
|
||||||
prefix,
|
prefix,
|
||||||
query,
|
query,
|
||||||
separator,
|
separator,
|
||||||
|
siteDescription,
|
||||||
siteName,
|
siteName,
|
||||||
slugs,
|
slugs,
|
||||||
}: BuildMetadataArgs): PageMetadata {
|
}: BuildMetadataArgs): PageMetadata {
|
||||||
// titleOverride wins outright: an editor who filled it in wants that exact
|
// Title source priority: titleOverride (exact, wins outright) > meta.title
|
||||||
// string in the tab, not a composition.
|
// (SEO title an editor set) > pageTitle (the document's own name). This means
|
||||||
|
// a page with no SEO title still shows its name (e.g. 'Sprzątanie biur')
|
||||||
|
// composed with the site name, instead of just the site name or a blank.
|
||||||
const override = meta?.titleOverride?.trim()
|
const override = meta?.titleOverride?.trim()
|
||||||
const title = override || composeTitle({ order, pageTitle: meta?.title, separator, siteName })
|
const resolvedPageTitle = meta?.title?.trim() || pageTitle?.trim() || undefined
|
||||||
const description = meta?.description?.trim() || undefined
|
const title =
|
||||||
|
override || composeTitle({ order, pageTitle: resolvedPageTitle, separator, siteName })
|
||||||
|
const description = meta?.description?.trim() || siteDescription?.trim() || undefined
|
||||||
const origin = baseUrl?.replace(/\/$/, '') ?? ''
|
const origin = baseUrl?.replace(/\/$/, '') ?? ''
|
||||||
|
|
||||||
const suffix = query ?? ''
|
const suffix = query ?? ''
|
||||||
@@ -118,5 +142,8 @@ export function buildMetadata({
|
|||||||
...(images && { images }),
|
...(images && { images }),
|
||||||
locale,
|
locale,
|
||||||
},
|
},
|
||||||
|
// noindex → tell search engines to exclude the page but still follow links
|
||||||
|
// (authority flows through). For legal/thin/search-result pages.
|
||||||
|
...(meta?.noindex ? { robots: { follow: true, index: false } } : {}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
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 function buildServiceJsonLd({
|
||||||
|
name,
|
||||||
|
areaServed,
|
||||||
|
description,
|
||||||
|
providerName,
|
||||||
|
serviceType,
|
||||||
|
url,
|
||||||
|
}: ServiceJsonLdArgs) {
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'Service',
|
||||||
|
provider: {
|
||||||
|
name: providerName,
|
||||||
|
'@type': 'LocalBusiness',
|
||||||
|
url,
|
||||||
|
},
|
||||||
|
...(description ? { description } : {}),
|
||||||
|
...(areaServed ? { areaServed } : {}),
|
||||||
|
...(serviceType ? { serviceType } : {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import type { ContentOption } from '../content/index.js'
|
|||||||
import type { I18nConfig } from '../i18n/index.js'
|
import type { I18nConfig } from '../i18n/index.js'
|
||||||
|
|
||||||
import { archiveFieldName } from '../content/index.js'
|
import { archiveFieldName } from '../content/index.js'
|
||||||
import { buildLocalizedPath, getLocalizedSlugs } from '../i18n/index.js'
|
import { buildLocalizedPath, getLocaleCodes , getLocalizedSlugs } from '../i18n/index.js'
|
||||||
import { buildHreflangAlternates } from './hreflang.js'
|
import { buildHreflangAlternates } from './hreflang.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -40,7 +40,7 @@ type BuildSitemapArgs = {
|
|||||||
* Slug of the page that is the site root (collapses to /{locale}).
|
* Slug of the page that is the site root (collapses to /{locale}).
|
||||||
* Read from System Pages when omitted.
|
* Read from System Pages when omitted.
|
||||||
*/
|
*/
|
||||||
homeSlug?: string
|
homeSlug?: Record<string, string> | string
|
||||||
/** Pages collection slug. Defaults to 'pages'. */
|
/** Pages collection slug. Defaults to 'pages'. */
|
||||||
pagesSlug?: string
|
pagesSlug?: string
|
||||||
payload: BasePayload
|
payload: BasePayload
|
||||||
@@ -56,10 +56,30 @@ type DocRow = {
|
|||||||
updatedAt?: string
|
updatedAt?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Skip drafts and anything flagged noindex in the SEO tab. */
|
/**
|
||||||
|
* Slugs that must never appear in the sitemap — error/system pages that exist as
|
||||||
|
* documents (e.g. a '404' page in the Pages collection) but should not be
|
||||||
|
* indexed. A sitemap should list only real, HTTP-200 content; a '/pl/404' entry
|
||||||
|
* is an audit finding. Matched against the slug in any locale.
|
||||||
|
*/
|
||||||
|
const EXCLUDED_SITEMAP_SLUGS = new Set(['404', '500', 'error', 'not-found'])
|
||||||
|
|
||||||
|
/** True if the doc's slug (in any locale) is an excluded system/error slug. */
|
||||||
|
function hasExcludedSlug(slug: unknown): boolean {
|
||||||
|
if (typeof slug === 'string') {return EXCLUDED_SITEMAP_SLUGS.has(slug)}
|
||||||
|
if (slug && typeof slug === 'object') {
|
||||||
|
for (const value of Object.values(slug as Record<string, unknown>)) {
|
||||||
|
if (typeof value === 'string' && EXCLUDED_SITEMAP_SLUGS.has(value)) {return true}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Skip drafts, noindex, and system/error pages (404 etc.). */
|
||||||
function isIndexable(doc: DocRow): boolean {
|
function isIndexable(doc: DocRow): boolean {
|
||||||
if (doc._status && doc._status !== 'published') {return false}
|
if (doc._status && doc._status !== 'published') {return false}
|
||||||
if (doc.meta?.noindex) {return false}
|
if (doc.meta?.noindex) {return false}
|
||||||
|
if (hasExcludedSlug(doc.slug)) {return false}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,7 +94,7 @@ function entryFor(
|
|||||||
locale: string,
|
locale: string,
|
||||||
config: I18nConfig,
|
config: I18nConfig,
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
homeSlug: string | undefined,
|
homeSlug: Record<string, string> | string | undefined,
|
||||||
prefix: Record<string, string> | undefined,
|
prefix: Record<string, string> | undefined,
|
||||||
changeFrequency: SitemapEntry['changeFrequency'],
|
changeFrequency: SitemapEntry['changeFrequency'],
|
||||||
): null | SitemapEntry {
|
): null | SitemapEntry {
|
||||||
@@ -139,8 +159,17 @@ export async function buildSitemapEntries({
|
|||||||
locale: 'all' as never,
|
locale: 'all' as never,
|
||||||
})) as Record<string, unknown>
|
})) as Record<string, unknown>
|
||||||
|
|
||||||
|
// Home slug as a per-locale MAP so every language's homepage collapses to its
|
||||||
|
// root in the sitemap (/pl, /de, /en) — not just the default locale. Priority:
|
||||||
|
// caller-provided homeSlug > full map from the homepage relationship > single
|
||||||
|
// default-locale slug > 'home'. slugMapAllLocales already builds the map (it's
|
||||||
|
// used for archive prefixes below); reusing it here fixes /de/startseite
|
||||||
|
// appearing in the sitemap instead of /de.
|
||||||
const resolvedHomeSlug =
|
const resolvedHomeSlug =
|
||||||
homeSlug ?? extractSlugInLocale(settings.homepage, defaultLocale) ?? 'home'
|
homeSlug ??
|
||||||
|
slugMapAllLocales(settings.homepage, locales) ??
|
||||||
|
extractSlugInLocale(settings.homepage, defaultLocale) ??
|
||||||
|
'home'
|
||||||
|
|
||||||
// Which collections to walk: pages (no prefix) + each content collection with
|
// Which collections to walk: pages (no prefix) + each content collection with
|
||||||
// its archive-page slugs as the localized prefix.
|
// its archive-page slugs as the localized prefix.
|
||||||
@@ -168,17 +197,23 @@ export async function buildSitemapEntries({
|
|||||||
for (const raw of result.docs as DocRow[]) {
|
for (const raw of result.docs as DocRow[]) {
|
||||||
if (!isIndexable(raw)) {continue}
|
if (!isIndexable(raw)) {continue}
|
||||||
|
|
||||||
// Emit the entry under the default locale's URL; alternates cover the rest.
|
// Emit a separate <url> for EACH locale. Google's sitemap spec for
|
||||||
const entry = entryFor(
|
// localized sites requires one <url> per language version (each with its
|
||||||
raw,
|
// own <loc> plus xhtml:link alternates), not a single default-locale <url>
|
||||||
defaultLocale,
|
// with the others hidden only in alternates. Emitting only the default
|
||||||
config,
|
// locale makes GSC count 8 URLs instead of 8×3 — a real reporting gap.
|
||||||
baseUrl,
|
for (const locale of getLocaleCodes(config)) {
|
||||||
resolvedHomeSlug,
|
const entry = entryFor(
|
||||||
collection.prefixSlugs,
|
raw,
|
||||||
changeFrequency,
|
locale,
|
||||||
)
|
config,
|
||||||
if (entry) {entries.push(entry)}
|
baseUrl,
|
||||||
|
resolvedHomeSlug,
|
||||||
|
collection.prefixSlugs,
|
||||||
|
changeFrequency,
|
||||||
|
)
|
||||||
|
if (entry) {entries.push(entry)}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,14 +2,14 @@
|
|||||||
export type TitleOrder = 'page-first' | 'site-first'
|
export type TitleOrder = 'page-first' | 'site-first'
|
||||||
|
|
||||||
type ComposeTitleArgs = {
|
type ComposeTitleArgs = {
|
||||||
/** Defaults to 'page-first' — the page title is what a visitor scans for. */
|
|
||||||
order?: TitleOrder
|
|
||||||
/** Page-specific title, e.g. 'About Us'. */
|
/** Page-specific title, e.g. 'About Us'. */
|
||||||
pageTitle?: null | string
|
pageTitle?: string | null
|
||||||
|
/** Site name, e.g. 'Acme Inc'. */
|
||||||
|
siteName?: string | null
|
||||||
/** Separator between page title and site name. Defaults to ' | '. */
|
/** Separator between page title and site name. Defaults to ' | '. */
|
||||||
separator?: string
|
separator?: string
|
||||||
/** Site name, e.g. 'Acme Inc'. */
|
/** Defaults to 'page-first' — the page title is what a visitor scans for. */
|
||||||
siteName?: null | string
|
order?: TitleOrder
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -24,15 +24,23 @@ type ComposeTitleArgs = {
|
|||||||
* Pure function — no dependency on Payload or request state.
|
* Pure function — no dependency on Payload or request state.
|
||||||
*/
|
*/
|
||||||
export function composeTitle({
|
export function composeTitle({
|
||||||
order = 'page-first',
|
|
||||||
pageTitle,
|
pageTitle,
|
||||||
separator = ' | ',
|
|
||||||
siteName,
|
siteName,
|
||||||
|
separator = ' | ',
|
||||||
|
order = 'page-first',
|
||||||
}: ComposeTitleArgs): string {
|
}: ComposeTitleArgs): string {
|
||||||
const page = pageTitle?.trim()
|
const page = pageTitle?.trim()
|
||||||
const site = siteName?.trim()
|
const site = siteName?.trim()
|
||||||
|
|
||||||
if (page && site) {
|
if (page && site) {
|
||||||
|
// Dedup: if the page title already ends with the site name (an editor wrote
|
||||||
|
// "Budowa auta – RCC" into meta.title), don't append it again → avoid
|
||||||
|
// "Budowa auta – RCC – RCC". Match the site name at the end, with or without
|
||||||
|
// the separator before it.
|
||||||
|
const sep = separator.trim()
|
||||||
|
if (page === site || page.endsWith(`${sep} ${site}`) || page.endsWith(` ${site}`)) {
|
||||||
|
return page
|
||||||
|
}
|
||||||
return order === 'site-first' ? `${site}${separator}${page}` : `${page}${separator}${site}`
|
return order === 'site-first' ? `${site}${separator}${page}` : `${page}${separator}${site}`
|
||||||
}
|
}
|
||||||
return page || site || ''
|
return page || site || ''
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ type CreateMetadataGeneratorArgs = {
|
|||||||
baseUrl?: string
|
baseUrl?: string
|
||||||
config: I18nConfig
|
config: I18nConfig
|
||||||
/** Home slug that collapses to the locale root. Defaults to 'home'. */
|
/** Home slug that collapses to the locale root. Defaults to 'home'. */
|
||||||
homeSlug?: string
|
homeSlug?: string | Record<string, string>
|
||||||
/**
|
/**
|
||||||
* Resolves the document to build metadata for, given route params.
|
* Resolves the document to build metadata for, given route params.
|
||||||
* The client supplies this (they own the collections and routing); it should
|
* The client supplies this (they own the collections and routing); it should
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ type PageMetadataContext = {
|
|||||||
type DocShape = {
|
type DocShape = {
|
||||||
id: number | string
|
id: number | string
|
||||||
meta?: null | SeoMeta
|
meta?: null | SeoMeta
|
||||||
|
/** The document's own title (page name), used as the fallback page title. */
|
||||||
|
title?: null | string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** plugin-seo stores the OG image as an upload relationship. */
|
/** plugin-seo stores the OG image as an upload relationship. */
|
||||||
@@ -151,6 +153,7 @@ export function createPageMetadata(args: CreatePageMetadataArgs) {
|
|||||||
...base,
|
...base,
|
||||||
imageUrl: resolveOgImage(doc),
|
imageUrl: resolveOgImage(doc),
|
||||||
meta: doc.meta,
|
meta: doc.meta,
|
||||||
|
pageTitle: doc.title,
|
||||||
prefix,
|
prefix,
|
||||||
query,
|
query,
|
||||||
slugs,
|
slugs,
|
||||||
|
|||||||
+19
-20
@@ -1,59 +1,58 @@
|
|||||||
import type { I18nConfig, LocalizedSlugs } from '../i18n/index.js'
|
import type { I18nConfig, LocalizedSlugs } from '../i18n/index.js'
|
||||||
|
import { buildLocalizedPath, getLocaleCodes } from '../i18n/index.js'
|
||||||
import { buildLocalizedPath, getDefaultLocale, getLocaleCodes } from '../i18n/index.js'
|
|
||||||
|
|
||||||
type BuildHreflangArgs = {
|
type BuildHreflangArgs = {
|
||||||
|
/** slug per locale for the current document, e.g. { pl: 'o-nas', en: 'about' } */
|
||||||
|
slugs: LocalizedSlugs
|
||||||
|
config: I18nConfig
|
||||||
/** Absolute site origin, e.g. 'https://example.com'. Omit for relative paths. */
|
/** Absolute site origin, e.g. 'https://example.com'. Omit for relative paths. */
|
||||||
baseUrl?: string
|
baseUrl?: string
|
||||||
config: I18nConfig
|
|
||||||
/** Home slug that collapses to the locale root. Defaults to 'home'. */
|
/** Home slug that collapses to the locale root. Defaults to 'home'. */
|
||||||
homeSlug?: string
|
homeSlug?: string | Record<string, string>
|
||||||
/**
|
/**
|
||||||
* Localized segment the document lives under (an archive page's slugs),
|
* Localized segment the document lives under (an archive page's slugs),
|
||||||
* e.g. { pl: 'artykuly', en: 'articles' }. Locales missing from the prefix
|
* e.g. { pl: 'artykuly', en: 'articles' }. Locales missing from the prefix
|
||||||
* are omitted — an entry with no archive in that language has no URL there.
|
* are omitted — an entry with no archive in that language has no URL there.
|
||||||
*/
|
*/
|
||||||
prefix?: LocalizedSlugs
|
prefix?: LocalizedSlugs
|
||||||
/** slug per locale for the current document, e.g. { pl: 'o-nas', en: 'about' } */
|
|
||||||
slugs: LocalizedSlugs
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* Builds a map of locale → URL for hreflang alternate links, suitable for
|
||||||
* Next.js Metadata `alternates.languages`.
|
* Next.js Metadata `alternates.languages`.
|
||||||
*
|
*
|
||||||
* Bridges SEO and i18n: for each configured locale that the document has a
|
* Bridges SEO and i18n: for each configured locale that the document has a
|
||||||
* slug in, it produces the locale-aware path (via buildLocalizedPath),
|
* slug in, it produces the locale-aware path (via buildLocalizedPath),
|
||||||
* optionally prefixed with an absolute origin.
|
* optionally prefixed with an absolute origin.
|
||||||
*
|
*
|
||||||
* Also emits `x-default` pointing at the default locale — the version Google
|
|
||||||
* serves when the user's language/region matches no hreflang, and the fallback
|
|
||||||
* when the root ('/') redirect is ambiguous (Googlebot with no/foreign
|
|
||||||
* Accept-Language).
|
|
||||||
*
|
|
||||||
* @example
|
* @example
|
||||||
* buildHreflangAlternates({
|
* buildHreflangAlternates({
|
||||||
* slugs: { pl: 'o-nas', en: 'about' },
|
* slugs: { pl: 'o-nas', en: 'about' },
|
||||||
* config,
|
* config,
|
||||||
* baseUrl: 'https://example.com',
|
* baseUrl: 'https://example.com',
|
||||||
* })
|
* })
|
||||||
* // → {
|
* // → { pl: 'https://example.com/pl/o-nas', en: 'https://example.com/en/about' }
|
||||||
* // pl: 'https://example.com/pl/o-nas',
|
|
||||||
* // en: 'https://example.com/en/about',
|
|
||||||
* // 'x-default': 'https://example.com/pl/o-nas',
|
|
||||||
* // }
|
|
||||||
*/
|
*/
|
||||||
export function buildHreflangAlternates({
|
export function buildHreflangAlternates({
|
||||||
baseUrl,
|
slugs,
|
||||||
config,
|
config,
|
||||||
|
baseUrl,
|
||||||
homeSlug = 'home',
|
homeSlug = 'home',
|
||||||
prefix,
|
prefix,
|
||||||
slugs,
|
|
||||||
}: BuildHreflangArgs): Record<string, string> {
|
}: BuildHreflangArgs): Record<string, string> {
|
||||||
const origin = baseUrl?.replace(/\/$/, '') ?? ''
|
const origin = baseUrl?.replace(/\/$/, '') ?? ''
|
||||||
const alternates: Record<string, string> = {}
|
const alternates: Record<string, string> = {}
|
||||||
|
|
||||||
|
// Single-locale sites have no language alternatives — hreflang describes
|
||||||
|
// relationships BETWEEN language versions, and there's only one. Emitting
|
||||||
|
// hreflang (or x-default) here would be wrong, so return empty: the page keeps
|
||||||
|
// its canonical, but no alternate-language links.
|
||||||
|
if (config.locales.length === 1) {
|
||||||
|
return alternates
|
||||||
|
}
|
||||||
|
|
||||||
for (const locale of getLocaleCodes(config)) {
|
for (const locale of getLocaleCodes(config)) {
|
||||||
const path = buildLocalizedPath({ config, homeSlug, locale, prefix, slugs })
|
const path = buildLocalizedPath({ slugs, locale, config, homeSlug, prefix })
|
||||||
if (path) {
|
if (path) {
|
||||||
alternates[locale] = `${origin}${path}`
|
alternates[locale] = `${origin}${path}`
|
||||||
}
|
}
|
||||||
@@ -64,7 +63,7 @@ export function buildHreflangAlternates({
|
|||||||
// redirect is ambiguous (Googlebot with no/foreign Accept-Language). Point it
|
// redirect is ambiguous (Googlebot with no/foreign Accept-Language). Point it
|
||||||
// at the default locale (the primary market) so search shows that version by
|
// at the default locale (the primary market) so search shows that version by
|
||||||
// default instead of guessing. Only set when the default locale has a URL.
|
// default instead of guessing. Only set when the default locale has a URL.
|
||||||
const defaultLocalePath = alternates[getDefaultLocale(config)]
|
const defaultLocalePath = alternates[config.defaultLocale]
|
||||||
if (defaultLocalePath) {
|
if (defaultLocalePath) {
|
||||||
alternates['x-default'] = defaultLocalePath
|
alternates['x-default'] = defaultLocalePath
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
export { buildAutoFillMetaHook } from './autoFillMeta.js'
|
export { buildAutoFillMetaHook } from './autoFillMeta.js'
|
||||||
export type { AutoFillMapping } from './autoFillMeta.js'
|
export type { AutoFillMapping } from './autoFillMeta.js'
|
||||||
|
export { buildArticleJsonLd } from './buildArticleJsonLd.js'
|
||||||
export { buildBreadcrumbJsonLd } from './buildBreadcrumbJsonLd.js'
|
export { buildBreadcrumbJsonLd } from './buildBreadcrumbJsonLd.js'
|
||||||
|
export { buildFaqJsonLd } from './buildFaqJsonLd.js'
|
||||||
export { buildIconsMetadata } from './buildIconsMetadata.js'
|
export { buildIconsMetadata } from './buildIconsMetadata.js'
|
||||||
|
export { buildLlmsTxt } from './buildLlmsTxt.js'
|
||||||
|
export { buildLocalBusinessJsonLd } from './buildLocalBusinessJsonLd.js'
|
||||||
export { buildMetadata } from './buildMetadata.js'
|
export { buildMetadata } from './buildMetadata.js'
|
||||||
export type { PageMetadata } from './buildMetadata.js'
|
export type { PageMetadata } from './buildMetadata.js'
|
||||||
export { buildOrganizationJsonLd } from './buildOrganizationJsonLd.js'
|
export { buildOrganizationJsonLd } from './buildOrganizationJsonLd.js'
|
||||||
export { buildRobots } from './buildRobots.js'
|
export { buildRobots } from './buildRobots.js'
|
||||||
export type { RobotsRules } from './buildRobots.js'
|
export type { RobotsRules } from './buildRobots.js'
|
||||||
|
export { buildServiceJsonLd } from './buildServiceJsonLd.js'
|
||||||
export { buildSitemapEntries } from './buildSitemapEntries.js'
|
export { buildSitemapEntries } from './buildSitemapEntries.js'
|
||||||
export type { SitemapEntry } from './buildSitemapEntries.js'
|
export type { SitemapEntry } from './buildSitemapEntries.js'
|
||||||
export { buildSiteNavigationJsonLd } from './buildSiteNavigationJsonLd.js'
|
export { buildSiteNavigationJsonLd } from './buildSiteNavigationJsonLd.js'
|
||||||
|
|||||||
@@ -10,11 +10,18 @@ type SettingsShape = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type SiteMetaConfig = {
|
export type SiteMetaConfig = {
|
||||||
/** Slug of the page assigned as Homepage; collapses to the locale root. */
|
/**
|
||||||
homeSlug?: string
|
* Home slug per locale, e.g. { pl: 'strona-glowna', de: 'startseite' }. A map
|
||||||
|
* (not a single string) so multilingual homepages collapse to root in EVERY
|
||||||
|
* language — otherwise hreflang points other languages at '/de/startseite'
|
||||||
|
* instead of '/de', breaking return tags (a real GSC error).
|
||||||
|
*/
|
||||||
|
homeSlug?: Record<string, string>
|
||||||
order?: TitleOrder
|
order?: TitleOrder
|
||||||
/** Padded separator, e.g. ' | ' — the panel stores the bare character. */
|
/** Padded separator, e.g. ' | ' — the panel stores the bare character. */
|
||||||
separator?: string
|
separator?: string
|
||||||
|
/** Global fallback meta description. */
|
||||||
|
siteDescription?: null | string
|
||||||
siteName: null | string
|
siteName: null | string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,10 +55,48 @@ export async function readSiteMetaConfig({
|
|||||||
const homepage =
|
const homepage =
|
||||||
settings.homepage && typeof settings.homepage === 'object' ? settings.homepage : null
|
settings.homepage && typeof settings.homepage === 'object' ? settings.homepage : null
|
||||||
|
|
||||||
|
// Build the per-locale home slug map. The homepage's slug is localized, so a
|
||||||
|
// single-locale read only gives THIS language's slug. Re-read the homepage
|
||||||
|
// with locale:'all' to get every language's slug — needed so hreflang can
|
||||||
|
// collapse each language's home to its root.
|
||||||
|
let homeSlug: Record<string, string> | undefined
|
||||||
|
if (homepage?.id) {
|
||||||
|
try {
|
||||||
|
const homeDoc = (await payload.findByID({
|
||||||
|
id: homepage.id,
|
||||||
|
collection: 'pages' as never,
|
||||||
|
depth: 0,
|
||||||
|
locale: 'all' as never,
|
||||||
|
})) as { slug?: unknown }
|
||||||
|
const slugField = homeDoc.slug
|
||||||
|
if (slugField && typeof slugField === 'object') {
|
||||||
|
const map: Record<string, string> = {}
|
||||||
|
for (const [loc, value] of Object.entries(slugField as Record<string, unknown>)) {
|
||||||
|
if (typeof value === 'string') {map[loc] = value}
|
||||||
|
}
|
||||||
|
if (Object.keys(map).length > 0) {homeSlug = map}
|
||||||
|
} else if (typeof slugField === 'string') {
|
||||||
|
// Non-localized slug (same in every language) — key it by current locale;
|
||||||
|
// buildLocalizedPath tolerates a partial map. Non-localized homepage
|
||||||
|
// slugs are rare, so this branch is a safety net, not the norm.
|
||||||
|
homeSlug = { [locale]: slugField }
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall back to the single-locale slug below.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: if the 'all' read didn't yield a map, use the current locale's slug
|
||||||
|
// for at least this locale (better than nothing).
|
||||||
|
if (!homeSlug && typeof homepage?.slug === 'string') {
|
||||||
|
homeSlug = { [locale]: homepage.slug }
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
siteDescription: (settings.siteDescription as string | undefined) ?? null,
|
||||||
siteName: (settings[siteNameField] as string | undefined) ?? null,
|
siteName: (settings[siteNameField] as string | undefined) ?? null,
|
||||||
// The panel stores '|'; titles need it padded.
|
// The panel stores '|'; titles need it padded.
|
||||||
homeSlug: typeof homepage?.slug === 'string' ? homepage.slug : undefined,
|
homeSlug,
|
||||||
order: settings.titleOrder ?? undefined,
|
order: settings.titleOrder ?? undefined,
|
||||||
separator: settings.titleSeparator ? ` ${settings.titleSeparator} ` : undefined,
|
separator: settings.titleSeparator ? ` ${settings.titleSeparator} ` : undefined,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export type SeoOption = {
|
|||||||
export type SeoMeta = {
|
export type SeoMeta = {
|
||||||
description?: null | string
|
description?: null | string
|
||||||
image?: unknown
|
image?: unknown
|
||||||
|
noindex?: boolean | null
|
||||||
title?: null | string
|
title?: null | string
|
||||||
/** When set, used as the whole title — no site name, no separator. */
|
/** When set, used as the whole title — no site name, no separator. */
|
||||||
titleOverride?: null | string
|
titleOverride?: null | string
|
||||||
|
|||||||
Reference in New Issue
Block a user