Compare commits
6
Commits
e39e2a361a
..
v1.2.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e30ac71044 | ||
|
|
781e348ded | ||
|
|
e16a18e488 | ||
|
|
7cd3cbaae5 | ||
|
|
60f07fddc9 | ||
|
|
068415849f |
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 { buildSecurityHeaders } from './modules/security/index.js';
|
||||
export type { BuildSecurityHeadersArgs, SecurityHeader } from './modules/security/index.js';
|
||||
export { buildIconsMetadata, buildOrganizationJsonLd, validateFaviconField, } from './modules/seo/index.js';
|
||||
export { buildFaqJsonLd, buildIconsMetadata, buildLocalBusinessJsonLd, buildOrganizationJsonLd, buildServiceJsonLd, validateFaviconField, } from './modules/seo/index.js';
|
||||
export { buildBreadcrumbJsonLd, buildSiteNavigationJsonLd, buildWebSiteJsonLd, } from './modules/seo/index.js';
|
||||
export type { PageMetadata, SeoMeta, SeoOption } from './modules/seo/index.js';
|
||||
export { buildHreflangAlternates, buildMetadata, composeTitle } from './modules/seo/index.js';
|
||||
|
||||
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 { getGlobal, getSiteIntegrations, getSiteSettings, SITE_INTEGRATIONS_SLUG, SITE_SETTINGS_SLUG } from './modules/payload/index.js';
|
||||
export { buildSecurityHeaders } from './modules/security/index.js';
|
||||
export { buildIconsMetadata, buildOrganizationJsonLd, validateFaviconField } from './modules/seo/index.js';
|
||||
export { buildFaqJsonLd, buildIconsMetadata, buildLocalBusinessJsonLd, buildOrganizationJsonLd, buildServiceJsonLd, validateFaviconField } from './modules/seo/index.js';
|
||||
// Structured data (schema.org JSON-LD) — brand/sitelink signals for Google.
|
||||
// WebSite (+ optional SearchAction), BreadcrumbList (per page), SiteNavigation.
|
||||
export { buildBreadcrumbJsonLd, buildSiteNavigationJsonLd, buildWebSiteJsonLd } from './modules/seo/index.js';
|
||||
|
||||
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 { ArchiveEntries, ContentOption, ResolvedRoute } from '../content/index.js';
|
||||
import type { ContentOption, ResolvedRoute, ArchiveEntries } from '../content/index.js';
|
||||
import type { I18nConfig } from '../i18n/index.js';
|
||||
import type { RobotsRules, SitemapEntry } from '../seo/index.js';
|
||||
import type { SitemapEntry, RobotsRules } from '../seo/index.js';
|
||||
type CreateContentHelpersArgs = {
|
||||
/**
|
||||
* Absolute site origin for sitemap/robots URLs. Falls back to
|
||||
* NEXT_PUBLIC_SERVER_URL, then to a relative origin (which most crawlers
|
||||
* reject, so set one in production).
|
||||
*/
|
||||
baseUrl?: string;
|
||||
/**
|
||||
* The client's payload config promise (the default export of payload.config).
|
||||
* Passed in because the plugin never imports the client's config directly.
|
||||
@@ -16,15 +10,21 @@ type CreateContentHelpersArgs = {
|
||||
config: Promise<SanitizedConfig> | SanitizedConfig;
|
||||
/** Archive-backed collections, same value as the plugin option. */
|
||||
content?: ContentOption;
|
||||
/** SiteSettings global slug. Defaults to 'site-settings'. */
|
||||
settingsSlug?: string;
|
||||
/** Pages collection slug. Defaults to 'pages'. */
|
||||
pagesSlug?: string;
|
||||
/**
|
||||
* i18n config. Required only if you want the ready-made `sitemap` / `robots`
|
||||
* handlers — they need the locale list to emit hreflang.
|
||||
*/
|
||||
i18n?: I18nConfig;
|
||||
/** Pages collection slug. Defaults to 'pages'. */
|
||||
pagesSlug?: string;
|
||||
/** SiteSettings global slug. Defaults to 'site-settings'. */
|
||||
settingsSlug?: string;
|
||||
/**
|
||||
* Absolute site origin for sitemap/robots URLs. Falls back to
|
||||
* NEXT_PUBLIC_SERVER_URL, then to a relative origin (which most crawlers
|
||||
* reject, so set one in production).
|
||||
*/
|
||||
baseUrl?: string;
|
||||
};
|
||||
/**
|
||||
* Bundles the per-request data helpers a frontend needs — the same cached
|
||||
@@ -52,13 +52,13 @@ type CreateContentHelpersArgs = {
|
||||
* a URL is, fetching gets the listing. Metadata generation needs the first and
|
||||
* not the second, and a page component composes them in two obvious lines.
|
||||
*/
|
||||
export declare function createContentHelpers({ baseUrl, config, content, i18n, pagesSlug, settingsSlug, }: CreateContentHelpersArgs): {
|
||||
export declare function createContentHelpers({ config, content, settingsSlug, pagesSlug, i18n, baseUrl, }: CreateContentHelpersArgs): {
|
||||
getCachedPayload: () => Promise<BasePayload>;
|
||||
getConfiguredLocales: () => Promise<string[]>;
|
||||
getEntries: (collection: string, locale: string, page: number, perPage: number) => Promise<ArchiveEntries>;
|
||||
getSettings: (locale: string) => Promise<import("payload").JsonObject>;
|
||||
resolveRoute: (locale: string, segments: string[] | undefined, page: number) => Promise<null | ResolvedRoute>;
|
||||
robots: () => RobotsRules;
|
||||
resolveRoute: (locale: string, segments: string[] | undefined, page: number) => Promise<ResolvedRoute | null>;
|
||||
getEntries: (collection: string, locale: string, page: number, perPage: number) => Promise<ArchiveEntries>;
|
||||
sitemap: () => Promise<SitemapEntry[]>;
|
||||
robots: () => RobotsRules;
|
||||
};
|
||||
export {};
|
||||
|
||||
+42
-22
@@ -1,7 +1,7 @@
|
||||
import { getPayload } from 'payload';
|
||||
import { cache } from 'react';
|
||||
import { getArchiveEntries, resolveRoute as resolveRouteRaw } from '../content/index.js';
|
||||
import { buildRobots, buildSitemapEntries } from '../seo/index.js';
|
||||
import { getPayload } from 'payload';
|
||||
import { resolveRoute as resolveRouteRaw, getArchiveEntries } from '../content/index.js';
|
||||
import { buildSitemapEntries, buildRobots } from '../seo/index.js';
|
||||
/**
|
||||
* Bundles the per-request data helpers a frontend needs — the same cached
|
||||
* wrappers every project was writing by hand (getPayload, settings, locale
|
||||
@@ -27,7 +27,7 @@ import { buildRobots, buildSitemapEntries } from '../seo/index.js';
|
||||
* `resolveRoute` and `getEntries` are separate on purpose: routing decides what
|
||||
* a URL is, fetching gets the listing. Metadata generation needs the first and
|
||||
* not the second, and a page component composes them in two obvious lines.
|
||||
*/ export function createContentHelpers({ baseUrl, config, content, i18n, pagesSlug = 'pages', settingsSlug = 'site-settings' }) {
|
||||
*/ export function createContentHelpers({ config, content, settingsSlug = 'site-settings', pagesSlug = 'pages', i18n, baseUrl }) {
|
||||
const origin = baseUrl ?? process.env.NEXT_PUBLIC_SERVER_URL ?? '';
|
||||
const getCachedPayload = cache(async ()=>getPayload({
|
||||
config: await config
|
||||
@@ -40,29 +40,29 @@ import { buildRobots, buildSitemapEntries } from '../seo/index.js';
|
||||
const payload = await getCachedPayload();
|
||||
return payload.findGlobal({
|
||||
slug: settingsSlug,
|
||||
depth: 2,
|
||||
locale: locale
|
||||
locale: locale,
|
||||
depth: 2
|
||||
});
|
||||
});
|
||||
/** What does this URL point at? Routing only — no listing data. */ const resolveRoute = cache(async (locale, segments, page)=>{
|
||||
const payload = await getCachedPayload();
|
||||
return resolveRouteRaw({
|
||||
content,
|
||||
locale,
|
||||
page,
|
||||
pagesSlug,
|
||||
payload,
|
||||
locale,
|
||||
segments,
|
||||
page,
|
||||
content,
|
||||
pagesSlug,
|
||||
settingsSlug
|
||||
});
|
||||
});
|
||||
/** One page of a collection's entries, for an archive listing. */ const getEntries = cache(async (collection, locale, page, perPage)=>{
|
||||
const payload = await getCachedPayload();
|
||||
return getArchiveEntries({
|
||||
payload,
|
||||
collection,
|
||||
locale,
|
||||
page,
|
||||
payload,
|
||||
perPage
|
||||
});
|
||||
});
|
||||
@@ -73,19 +73,39 @@ import { buildRobots, buildSitemapEntries } from '../seo/index.js';
|
||||
* ```ts
|
||||
* // app/sitemap.ts
|
||||
* export { sitemap as default } from '@/lib/content'
|
||||
* export const dynamic = 'force-dynamic' // generate at runtime, not build
|
||||
* ```
|
||||
*
|
||||
* IMPORTANT — container deploys (Coolify/Docker/Railway/CI): Next treats
|
||||
* sitemap.ts as STATIC by default and prerenders it during `next build`, which
|
||||
* calls into Payload → the database. The build container usually has no access
|
||||
* to the internal Docker network, so the DB connection fails (ENOTFOUND) and
|
||||
* the build dies. Two defenses:
|
||||
* 1. `export const dynamic = 'force-dynamic'` in app/sitemap.ts — skips build
|
||||
* prerender, generates at runtime when the DB is reachable (recommended).
|
||||
* 2. This handler also catches DB errors and returns [] so that even without
|
||||
* (1) the build won't crash — it just ships an empty sitemap until the
|
||||
* next runtime regeneration. (1) is still preferred; (2) is a safety net.
|
||||
*/ const sitemap = cache(async ()=>{
|
||||
if (!i18n) {
|
||||
throw new Error('[ipal] createContentHelpers: pass `i18n` to use the sitemap handler.');
|
||||
}
|
||||
return buildSitemapEntries({
|
||||
baseUrl: origin,
|
||||
config: i18n,
|
||||
content,
|
||||
pagesSlug,
|
||||
payload: await getCachedPayload(),
|
||||
settingsSlug
|
||||
});
|
||||
try {
|
||||
return await buildSitemapEntries({
|
||||
payload: await getCachedPayload(),
|
||||
config: i18n,
|
||||
baseUrl: origin,
|
||||
content,
|
||||
pagesSlug,
|
||||
settingsSlug
|
||||
});
|
||||
} catch (error) {
|
||||
// DB unreachable (typically a container build with no DB network) — return
|
||||
// an empty sitemap instead of failing the build. Runtime regeneration will
|
||||
// produce the real one once the DB is reachable. See dynamic='force-dynamic'.
|
||||
console.warn('[ipal] sitemap: could not reach the database, returning empty entries ' + "(add `export const dynamic = 'force-dynamic'` to app/sitemap.ts to " + 'generate at runtime and avoid build-time DB access):', error);
|
||||
return [];
|
||||
}
|
||||
});
|
||||
/**
|
||||
* Ready-made handler for Next's `app/robots.ts`. Re-export directly:
|
||||
@@ -100,11 +120,11 @@ import { buildRobots, buildSitemapEntries } from '../seo/index.js';
|
||||
return {
|
||||
getCachedPayload,
|
||||
getConfiguredLocales,
|
||||
getEntries,
|
||||
getSettings,
|
||||
resolveRoute,
|
||||
robots,
|
||||
sitemap
|
||||
getEntries,
|
||||
sitemap,
|
||||
robots
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
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"}
|
||||
+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
+13
-1
@@ -19,6 +19,11 @@ export type PageMetadata = {
|
||||
locale?: string;
|
||||
title: string;
|
||||
};
|
||||
/** robots directives — set to noindex/follow for legal/thin/search pages. */
|
||||
robots?: {
|
||||
follow: boolean;
|
||||
index: boolean;
|
||||
};
|
||||
title: string;
|
||||
};
|
||||
type BuildMetadataArgs = {
|
||||
@@ -35,6 +40,13 @@ type BuildMetadataArgs = {
|
||||
meta?: null | SeoMeta;
|
||||
/** Page title or site name first. Defaults to 'page-first'. */
|
||||
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).
|
||||
* Feeds both canonical and hreflang, so /pl/artykuly/moj-post and
|
||||
@@ -65,5 +77,5 @@ type BuildMetadataArgs = {
|
||||
* pieces (meta group, site name, image URL, localized slugs) and passes them
|
||||
* 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, siteName, slugs, }: BuildMetadataArgs): PageMetadata;
|
||||
export {};
|
||||
|
||||
Vendored
+16
-5
@@ -9,13 +9,16 @@ import { buildHreflangAlternates } from './hreflang.js';
|
||||
* Designed for use inside Next.js `generateMetadata`. The caller resolves the
|
||||
* pieces (meta group, site name, image URL, localized slugs) and passes them
|
||||
* in — the plugin composes, it doesn't fetch.
|
||||
*/ export function buildMetadata({ baseUrl, config, homeSlug = 'home', imageUrl, locale, meta, order, prefix, query, separator, siteName, slugs }) {
|
||||
// titleOverride wins outright: an editor who filled it in wants that exact
|
||||
// string in the tab, not a composition.
|
||||
*/ export function buildMetadata({ baseUrl, config, homeSlug = 'home', imageUrl, locale, meta, order, pageTitle, prefix, query, separator, siteName, slugs }) {
|
||||
// Title source priority: titleOverride (exact, wins outright) > meta.title
|
||||
// (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 resolvedPageTitle = meta?.title?.trim() || pageTitle?.trim() || undefined;
|
||||
const title = override || composeTitle({
|
||||
order,
|
||||
pageTitle: meta?.title,
|
||||
pageTitle: resolvedPageTitle,
|
||||
separator,
|
||||
siteName
|
||||
});
|
||||
@@ -69,7 +72,15 @@ import { buildHreflangAlternates } from './hreflang.js';
|
||||
images
|
||||
},
|
||||
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"}
|
||||
+13
-13
@@ -1,6 +1,6 @@
|
||||
import type { BasePayload } from 'payload';
|
||||
import type { ContentOption } from '../content/index.js';
|
||||
import type { I18nConfig } from '../i18n/index.js';
|
||||
import type { ContentOption } from '../content/index.js';
|
||||
/**
|
||||
* One sitemap entry, shaped for Next's `app/sitemap.ts`.
|
||||
*
|
||||
@@ -10,31 +10,31 @@ import type { I18nConfig } from '../i18n/index.js';
|
||||
* is the common, weaker kind.
|
||||
*/
|
||||
export type SitemapEntry = {
|
||||
url: string;
|
||||
lastModified?: string | Date;
|
||||
changeFrequency?: 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never';
|
||||
priority?: number;
|
||||
alternates?: {
|
||||
languages: Record<string, string>;
|
||||
};
|
||||
changeFrequency?: 'always' | 'daily' | 'hourly' | 'monthly' | 'never' | 'weekly' | 'yearly';
|
||||
lastModified?: Date | string;
|
||||
priority?: number;
|
||||
url: string;
|
||||
};
|
||||
type BuildSitemapArgs = {
|
||||
payload: BasePayload;
|
||||
config: I18nConfig;
|
||||
/** Absolute origin, e.g. 'https://example.com'. Required for valid sitemap URLs. */
|
||||
baseUrl: string;
|
||||
changeFrequency?: SitemapEntry['changeFrequency'];
|
||||
config: I18nConfig;
|
||||
/** Pages collection slug. Defaults to 'pages'. */
|
||||
pagesSlug?: string;
|
||||
/** Archive-backed collections, same value as the plugin option. */
|
||||
content?: ContentOption;
|
||||
/** SiteSettings global slug. Defaults to 'site-settings'. */
|
||||
settingsSlug?: string;
|
||||
/**
|
||||
* Slug of the page that is the site root (collapses to /{locale}).
|
||||
* Read from System Pages when omitted.
|
||||
*/
|
||||
homeSlug?: string;
|
||||
/** Pages collection slug. Defaults to 'pages'. */
|
||||
pagesSlug?: string;
|
||||
payload: BasePayload;
|
||||
/** SiteSettings global slug. Defaults to 'site-settings'. */
|
||||
settingsSlug?: string;
|
||||
changeFrequency?: SitemapEntry['changeFrequency'];
|
||||
};
|
||||
/**
|
||||
* Collects every public URL — pages and archive entries — as sitemap entries
|
||||
@@ -57,5 +57,5 @@ type BuildSitemapArgs = {
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export declare function buildSitemapEntries({ baseUrl, changeFrequency, config, content, homeSlug, pagesSlug, payload, settingsSlug, }: BuildSitemapArgs): Promise<SitemapEntry[]>;
|
||||
export declare function buildSitemapEntries({ payload, config, baseUrl, pagesSlug, content, settingsSlug, homeSlug, changeFrequency, }: BuildSitemapArgs): Promise<SitemapEntry[]>;
|
||||
export {};
|
||||
|
||||
+46
-44
@@ -1,13 +1,31 @@
|
||||
import { archiveFieldName } from '../content/index.js';
|
||||
import { buildLocalizedPath, getLocalizedSlugs } from '../i18n/index.js';
|
||||
import { getLocalizedSlugs } from '../i18n/index.js';
|
||||
import { buildLocalizedPath } from '../i18n/index.js';
|
||||
import { buildHreflangAlternates } from './hreflang.js';
|
||||
/** Skip drafts and anything flagged noindex in the SEO tab. */ function isIndexable(doc) {
|
||||
if (doc._status && doc._status !== 'published') {
|
||||
return false;
|
||||
}
|
||||
if (doc.meta?.noindex) {
|
||||
return false;
|
||||
import { archiveFieldName } from '../content/index.js';
|
||||
/**
|
||||
* 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',
|
||||
'not-found',
|
||||
'error'
|
||||
]);
|
||||
/** 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') return false;
|
||||
if (doc.meta?.noindex) return false;
|
||||
if (hasExcludedSlug(doc.slug)) return false;
|
||||
return true;
|
||||
}
|
||||
/**
|
||||
@@ -17,26 +35,24 @@ import { buildHreflangAlternates } from './hreflang.js';
|
||||
* every locale (including itself, per Google's guidance).
|
||||
*/ function entryFor(doc, locale, config, baseUrl, homeSlug, prefix, changeFrequency) {
|
||||
const slugs = doc.slug && typeof doc.slug === 'object' ? getLocalizedSlugs({
|
||||
config,
|
||||
slugField: doc.slug
|
||||
slugField: doc.slug,
|
||||
config
|
||||
}) : {};
|
||||
const path = buildLocalizedPath({
|
||||
slugs,
|
||||
locale,
|
||||
config,
|
||||
homeSlug,
|
||||
locale,
|
||||
prefix,
|
||||
slugs
|
||||
prefix
|
||||
});
|
||||
if (!path) {
|
||||
return null;
|
||||
}
|
||||
if (!path) return null;
|
||||
const origin = baseUrl.replace(/\/$/, '');
|
||||
const languages = buildHreflangAlternates({
|
||||
baseUrl,
|
||||
slugs,
|
||||
config,
|
||||
baseUrl,
|
||||
homeSlug,
|
||||
prefix,
|
||||
slugs
|
||||
prefix
|
||||
});
|
||||
return {
|
||||
url: `${origin}${path}`,
|
||||
@@ -73,15 +89,15 @@ import { buildHreflangAlternates } from './hreflang.js';
|
||||
* })
|
||||
* }
|
||||
* ```
|
||||
*/ export async function buildSitemapEntries({ baseUrl, changeFrequency = 'weekly', config, content, homeSlug, pagesSlug = 'pages', payload, settingsSlug = 'site-settings' }) {
|
||||
*/ export async function buildSitemapEntries({ payload, config, baseUrl, pagesSlug = 'pages', content, settingsSlug = 'site-settings', homeSlug, changeFrequency = 'weekly' }) {
|
||||
const locales = config.locales.map((l)=>l.code);
|
||||
const defaultLocale = config.defaultLocale;
|
||||
// Resolve homeSlug and archive prefixes from System Pages (read once, in all
|
||||
// locales so archive prefixes are available per language).
|
||||
const settings = await payload.findGlobal({
|
||||
slug: settingsSlug,
|
||||
depth: 1,
|
||||
locale: 'all'
|
||||
locale: 'all',
|
||||
depth: 1
|
||||
});
|
||||
const resolvedHomeSlug = homeSlug ?? extractSlugInLocale(settings.homepage, defaultLocale) ?? 'home';
|
||||
// Which collections to walk: pages (no prefix) + each content collection with
|
||||
@@ -105,32 +121,24 @@ import { buildHreflangAlternates } from './hreflang.js';
|
||||
// alternates without re-querying per locale.
|
||||
const result = await payload.find({
|
||||
collection: collection.slug,
|
||||
locale: 'all',
|
||||
depth: 0,
|
||||
limit: 0,
|
||||
locale: 'all',
|
||||
pagination: false
|
||||
});
|
||||
for (const raw of result.docs){
|
||||
if (!isIndexable(raw)) {
|
||||
continue;
|
||||
}
|
||||
if (!isIndexable(raw)) continue;
|
||||
// Emit the entry under the default locale's URL; alternates cover the rest.
|
||||
const entry = entryFor(raw, defaultLocale, config, baseUrl, resolvedHomeSlug, collection.prefixSlugs, changeFrequency);
|
||||
if (entry) {
|
||||
entries.push(entry);
|
||||
}
|
||||
if (entry) entries.push(entry);
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
/** Pulls a slug string from a populated relationship in a specific locale. */ function extractSlugInLocale(rel, locale) {
|
||||
if (!rel || typeof rel !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
if (!rel || typeof rel !== 'object') return undefined;
|
||||
const slug = rel.slug;
|
||||
if (typeof slug === 'string') {
|
||||
return slug;
|
||||
}
|
||||
if (typeof slug === 'string') return slug;
|
||||
if (slug && typeof slug === 'object') {
|
||||
const v = slug[locale];
|
||||
return typeof v === 'string' ? v : undefined;
|
||||
@@ -138,19 +146,13 @@ import { buildHreflangAlternates } from './hreflang.js';
|
||||
return undefined;
|
||||
}
|
||||
/** Builds a locale→slug map from a populated archive relationship. */ function slugMapAllLocales(rel, locales) {
|
||||
if (!rel || typeof rel !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
if (!rel || typeof rel !== 'object') return undefined;
|
||||
const slug = rel.slug;
|
||||
if (!slug || typeof slug !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
if (!slug || typeof slug !== 'object') return undefined;
|
||||
const map = {};
|
||||
for (const locale of locales){
|
||||
const v = slug[locale];
|
||||
if (typeof v === 'string') {
|
||||
map[locale] = v;
|
||||
}
|
||||
if (typeof v === 'string') map[locale] = v;
|
||||
}
|
||||
return Object.keys(map).length ? map : undefined;
|
||||
}
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
@@ -90,6 +90,7 @@ import { slugsAcrossLocales } from './slugsAcrossLocales.js';
|
||||
...base,
|
||||
imageUrl: resolveOgImage(doc),
|
||||
meta: doc.meta,
|
||||
pageTitle: doc.title,
|
||||
prefix,
|
||||
query,
|
||||
slugs
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+3
@@ -1,12 +1,15 @@
|
||||
export { buildAutoFillMetaHook } from './autoFillMeta.js';
|
||||
export type { AutoFillMapping } from './autoFillMeta.js';
|
||||
export { buildBreadcrumbJsonLd } from './buildBreadcrumbJsonLd.js';
|
||||
export { buildFaqJsonLd } from './buildFaqJsonLd.js';
|
||||
export { buildIconsMetadata } from './buildIconsMetadata.js';
|
||||
export { buildLocalBusinessJsonLd } from './buildLocalBusinessJsonLd.js';
|
||||
export { buildMetadata } from './buildMetadata.js';
|
||||
export type { PageMetadata } from './buildMetadata.js';
|
||||
export { buildOrganizationJsonLd } from './buildOrganizationJsonLd.js';
|
||||
export { buildRobots } from './buildRobots.js';
|
||||
export type { RobotsRules } from './buildRobots.js';
|
||||
export { buildServiceJsonLd } from './buildServiceJsonLd.js';
|
||||
export { buildSitemapEntries } from './buildSitemapEntries.js';
|
||||
export type { SitemapEntry } from './buildSitemapEntries.js';
|
||||
export { buildSiteNavigationJsonLd } from './buildSiteNavigationJsonLd.js';
|
||||
|
||||
Vendored
+3
@@ -1,9 +1,12 @@
|
||||
export { buildAutoFillMetaHook } from './autoFillMeta.js';
|
||||
export { buildBreadcrumbJsonLd } from './buildBreadcrumbJsonLd.js';
|
||||
export { buildFaqJsonLd } from './buildFaqJsonLd.js';
|
||||
export { buildIconsMetadata } from './buildIconsMetadata.js';
|
||||
export { buildLocalBusinessJsonLd } from './buildLocalBusinessJsonLd.js';
|
||||
export { buildMetadata } from './buildMetadata.js';
|
||||
export { buildOrganizationJsonLd } from './buildOrganizationJsonLd.js';
|
||||
export { buildRobots } from './buildRobots.js';
|
||||
export { buildServiceJsonLd } from './buildServiceJsonLd.js';
|
||||
export { buildSitemapEntries } from './buildSitemapEntries.js';
|
||||
export { buildSiteNavigationJsonLd } from './buildSiteNavigationJsonLd.js';
|
||||
export { buildWebSiteJsonLd } from './buildWebSiteJsonLd.js';
|
||||
|
||||
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 { buildBreadcrumbJsonLd } from './buildBreadcrumbJsonLd.js'\nexport { buildFaqJsonLd } from './buildFaqJsonLd.js'\nexport { buildIconsMetadata } from './buildIconsMetadata.js'\nexport { buildLocalBusinessJsonLd } from './buildLocalBusinessJsonLd.js'\nexport { buildMetadata } from './buildMetadata.js'\nexport type { PageMetadata } from './buildMetadata.js'\nexport { buildOrganizationJsonLd } from './buildOrganizationJsonLd.js'\nexport { buildRobots } from './buildRobots.js'\nexport type { RobotsRules } from './buildRobots.js'\nexport { buildServiceJsonLd } from './buildServiceJsonLd.js'\nexport { buildSitemapEntries } from './buildSitemapEntries.js'\nexport type { SitemapEntry } from './buildSitemapEntries.js'\nexport { buildSiteNavigationJsonLd } from './buildSiteNavigationJsonLd.js'\nexport { buildWebSiteJsonLd } from './buildWebSiteJsonLd.js'\nexport { composeTitle } from './composeTitle.js'\nexport type { TitleOrder } from './composeTitle.js'\nexport { createMetadataGenerator } from './createMetadataGenerator.js'\nexport { createPageMetadata } from './createPageMetadata.js'\nexport { buildHreflangAlternates } from './hreflang.js'\nexport { injectAutoFillMeta } from './injectAutoFillMeta.js'\nexport { injectSeoTabs } from './injectSeoTabs.js'\nexport { readSiteMetaConfig } from './readSiteMetaConfig.js'\nexport type { SiteMetaConfig } from './readSiteMetaConfig.js'\nexport { buildSeoPlugin } from './seoPluginConfig.js'\nexport { slugsAcrossLocales } from './slugsAcrossLocales.js'\nexport type { SeoMeta, SeoOption } from './types.js'\nexport { validateFaviconField } from './validateFavicon.js'\n"],"names":["buildAutoFillMetaHook","buildBreadcrumbJsonLd","buildFaqJsonLd","buildIconsMetadata","buildLocalBusinessJsonLd","buildMetadata","buildOrganizationJsonLd","buildRobots","buildServiceJsonLd","buildSitemapEntries","buildSiteNavigationJsonLd","buildWebSiteJsonLd","composeTitle","createMetadataGenerator","createPageMetadata","buildHreflangAlternates","injectAutoFillMeta","injectSeoTabs","readSiteMetaConfig","buildSeoPlugin","slugsAcrossLocales","validateFaviconField"],"mappings":"AAAA,SAASA,qBAAqB,QAAQ,oBAAmB;AAEzD,SAASC,qBAAqB,QAAQ,6BAA4B;AAClE,SAASC,cAAc,QAAQ,sBAAqB;AACpD,SAASC,kBAAkB,QAAQ,0BAAyB;AAC5D,SAASC,wBAAwB,QAAQ,gCAA+B;AACxE,SAASC,aAAa,QAAQ,qBAAoB;AAElD,SAASC,uBAAuB,QAAQ,+BAA8B;AACtE,SAASC,WAAW,QAAQ,mBAAkB;AAE9C,SAASC,kBAAkB,QAAQ,0BAAyB;AAC5D,SAASC,mBAAmB,QAAQ,2BAA0B;AAE9D,SAASC,yBAAyB,QAAQ,iCAAgC;AAC1E,SAASC,kBAAkB,QAAQ,0BAAyB;AAC5D,SAASC,YAAY,QAAQ,oBAAmB;AAEhD,SAASC,uBAAuB,QAAQ,+BAA8B;AACtE,SAASC,kBAAkB,QAAQ,0BAAyB;AAC5D,SAASC,uBAAuB,QAAQ,gBAAe;AACvD,SAASC,kBAAkB,QAAQ,0BAAyB;AAC5D,SAASC,aAAa,QAAQ,qBAAoB;AAClD,SAASC,kBAAkB,QAAQ,0BAAyB;AAE5D,SAASC,cAAc,QAAQ,uBAAsB;AACrD,SAASC,kBAAkB,QAAQ,0BAAyB;AAE5D,SAASC,oBAAoB,QAAQ,uBAAsB"}
|
||||
Vendored
+1
@@ -34,6 +34,7 @@ export type SeoOption = {
|
||||
export type SeoMeta = {
|
||||
description?: null | string;
|
||||
image?: unknown;
|
||||
noindex?: boolean | null;
|
||||
title?: null | string;
|
||||
/** When set, used as the whole title — no site name, no separator. */
|
||||
titleOverride?: null | string;
|
||||
|
||||
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"}
|
||||
@@ -0,0 +1,206 @@
|
||||
# Deployment — zmienne środowiskowe i produkcja
|
||||
|
||||
Jedno źródło prawdy o zmiennych środowiskowych (wszystkie, co znaczą, wymagane
|
||||
czy nie) oraz jak wdrożyć projekt na produkcję spójnie. Env jest częścią
|
||||
deploymentu — te same zmienne w dev (.env) i na produkcji (runtime hostingu).
|
||||
|
||||
Powiązane: [getting-started.md](./getting-started.md), [storage.md](./storage.md)
|
||||
(R2), [email.md](./email.md) (Graph), [security.md](./security.md).
|
||||
|
||||
---
|
||||
|
||||
## 1. ZMIENNE ŚRODOWISKOWE — pełna lista
|
||||
|
||||
### Rdzeń (WYMAGANE — projekt bez nich nie wstanie)
|
||||
|
||||
```bash
|
||||
# Baza danych (Mongo albo Postgres — zależnie od projektu)
|
||||
DATABASE_URI=mongodb://... # albo postgres://... / file:./dev.db (dev)
|
||||
|
||||
# Sekret Payload (podpisywanie sesji/tokenów) — losowy, długi
|
||||
PAYLOAD_SECRET=<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).
|
||||
|
||||
## 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
|
||||
@@ -55,6 +55,21 @@ buildLocalizedPath({ slugs, locale: 'en', config }) // '/en/about'
|
||||
buildLocalizedPath({ slugs: { en: 'home' }, locale: 'en', config }) // '/en'
|
||||
```
|
||||
|
||||
> **Pułapka typu (TypeScript):** przy `locale: 'all'` Payload w RUNTIME zwraca
|
||||
> zlokalizowane pole jako obiekt `{ pl, en }`, ale wygenerowane typy Payloada
|
||||
> deklarują `doc.slug` jako `string` (typ nie odróżnia trybu `all`). `tsc`
|
||||
> zgłosi więc niezgodność. Rozwiązanie — czyste rzutowanie na oczekiwany przez
|
||||
> helper typ:
|
||||
> ```ts
|
||||
> const slugs = getLocalizedSlugs({
|
||||
> slugField: doc.slug as unknown as Record<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)
|
||||
|
||||
```ts
|
||||
|
||||
@@ -62,3 +62,80 @@ ZAWSZE wyłączaj w dev: `hsts: process.env.NODE_ENV === 'production'`.
|
||||
`additional` z tym samym kluczem NADPISUJE domyślny (np. zmień X-Frame-Options
|
||||
na SAMEORIGIN). Nowy klucz (jak CSP) dodaje. CSP zawsze przez `additional` —
|
||||
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.
|
||||
+197
-23
@@ -217,6 +217,7 @@ export const { /* ... */, sitemap, robots } = createContentHelpers({
|
||||
```ts
|
||||
// app/sitemap.ts
|
||||
export { sitemap as default } from '@/lib/content'
|
||||
export const dynamic = 'force-dynamic' // generuj w runtime, nie w buildzie
|
||||
|
||||
// app/robots.ts
|
||||
export { robots as default } from '@/lib/content'
|
||||
@@ -227,6 +228,16 @@ całkiem w pluginie — Next tworzy te trasy wyłącznie z plików w `app/`, ska
|
||||
katalog projektu, nie node_modules. Ale re-eksport to maksimum redukcji: cała
|
||||
logika jest w pluginie.
|
||||
|
||||
> **Deploy kontenerowy (Coolify/Docker/Railway/CI) — WAŻNE:** `export const
|
||||
> dynamic = 'force-dynamic'` w `app/sitemap.ts` jest KONIECZNE. Bez niego Next
|
||||
> traktuje sitemap jako statyczny i prerenderuje go w `next build` — a to
|
||||
> wywołuje Payload → bazę. Kontener budujący zwykle nie ma dostępu do sieci
|
||||
> Docker, więc połączenie z bazą pada (`ENOTFOUND`) i build się wywala. Z
|
||||
> `force-dynamic` sitemap generuje się w runtime, gdy baza jest dostępna.
|
||||
> (Plugin dodatkowo łapie błąd bazy i zwraca pusty sitemap zamiast wywalić build
|
||||
> — ale `force-dynamic` to właściwe rozwiązanie, nie poleganie na fallbacku.)
|
||||
> Opcjonalnie `export const revalidate = 3600` — cache sitemap na godzinę.
|
||||
|
||||
Co zawiera sitemapa:
|
||||
- każdą stronę i wpis bloga, URL w domyślnym locale
|
||||
- `alternates.languages` → Next renderuje `<xhtml:link rel="alternate" hreflang>`
|
||||
@@ -354,51 +365,88 @@ poprawnie: czytając z panelu/env, nie zaszywając wartości klienta.
|
||||
|
||||
### Web App Manifest (PWA) — jak zrobić DOBRZE
|
||||
|
||||
Zasada nadrzędna: **brak danych → POMIŃ pole, NIE zaszywaj wartości.** Manifest
|
||||
jest ważny bez `name`? Nie — ale lepszy manifest bez nazwy niż z cudzą nazwą
|
||||
klienta w fallbacku. Fallback z nazwą/kolorem klienta to ukryty hardkod.
|
||||
|
||||
```ts
|
||||
// app/manifest.ts
|
||||
import type { MetadataRoute } from 'next'
|
||||
import { getCachedPayload } from '@/lib/content'
|
||||
import { getSiteSettings } from '@intecion/ipal-kit'
|
||||
import { i18nConfig } from '@/i18n.config'
|
||||
import type { SiteSetting } from '@/payload-types'
|
||||
|
||||
export default async function manifest(): Promise<MetadataRoute.Manifest> {
|
||||
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),
|
||||
// nie ze statycznej ścieżki.
|
||||
const iconUrl =
|
||||
typeof settings.logo === 'object' && settings.logo?.url ? settings.logo.url : undefined
|
||||
const siteName = settings?.siteName?.trim()
|
||||
|
||||
// Ikona z panelu (favicon → logo). Dla PNG podaj KONKRETNY rozmiar z media
|
||||
// (nie 'any' — 'any' jest tylko dla SVG). Bez ikony → pomiń pole icons.
|
||||
const icon = settings?.favicon ?? settings?.logo
|
||||
const iconEntry =
|
||||
typeof icon === 'object' && icon?.url
|
||||
? (() => {
|
||||
const isSvg = icon.mimeType === 'image/svg+xml' || icon.url.endsWith('.svg')
|
||||
const size =
|
||||
typeof icon.width === 'number' && typeof icon.height === 'number'
|
||||
? `${Math.min(icon.width, icon.height)}x${Math.min(icon.width, icon.height)}`
|
||||
: '512x512'
|
||||
return {
|
||||
src: icon.url,
|
||||
type: icon.mimeType ?? 'image/png',
|
||||
sizes: isSvg ? 'any' : size, // 'any' tylko dla SVG
|
||||
}
|
||||
})()
|
||||
: undefined
|
||||
|
||||
// Buduj TYLKO z tego, co jest. Brak pola → nie ma go w manifeście (zamiast
|
||||
// zaszytego fallbacku). start_url z configu, nie zaszyte '/pl'.
|
||||
return {
|
||||
name: settings.siteName ?? '',
|
||||
short_name: settings.siteName ?? '', // albo osobne pole, jeśli dodasz
|
||||
start_url: '/',
|
||||
...(siteName ? { name: siteName, short_name: siteName } : {}),
|
||||
start_url: `/${i18nConfig.defaultLocale}`,
|
||||
display: 'standalone',
|
||||
...(iconUrl
|
||||
? { icons: [{ src: iconUrl, sizes: 'any', type: 'image/svg+xml' }] }
|
||||
: {}),
|
||||
// description / theme_color / background_color:
|
||||
// jeśli klient ich potrzebuje, DODAJ POLA w SiteSettings i czytaj stąd —
|
||||
// NIE wpisuj '#d4af37' na sztywno. Bez pól — pomiń (manifest działa bez nich).
|
||||
...(iconEntry ? { icons: [iconEntry] } : {}),
|
||||
// theme_color / background_color / description — TYLKO jeśli dodasz pola w
|
||||
// panelu i je odczytasz. NIE zaszywaj '#0e1e24' ani opisu klienta.
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Kontrast — czego NIE robić** (realny błąd z sesji):
|
||||
**Kluczowe różnice od częstego błędu agenta:**
|
||||
- **Brak fallbacku z nazwą klienta** — `siteName` puste → pomijamy `name`, nie
|
||||
wstawiamy „Kancelaria X" na sztywno. Cudza nazwa w fallbacku = hardkod.
|
||||
- **PNG dostaje konkretny `sizes`** z wymiarów media (nie `sizes: 'any'` — to
|
||||
ten sam błąd co przy favicon; `any` tylko dla SVG).
|
||||
- **Brak bloku `catch` z hardkodami** — jeśli boisz się błędu, opakuj samo
|
||||
`getSiteSettings` i przy błędzie zwróć minimalny manifest (start_url + display),
|
||||
BEZ zaszytej nazwy/kolorów.
|
||||
- **start_url z i18nConfig**, nie zaszyte `/pl`.
|
||||
|
||||
**Kontrast — czego NIE robić** (realne błędy z projektów):
|
||||
|
||||
```ts
|
||||
// ŹLE — wszystko zaszyte, zadziała tylko dla jednego klienta
|
||||
let name = 'R Custom Cars' // hardkod nazwy
|
||||
short_name: 'RCC', // hardkod
|
||||
description: 'Custom car styling...', // hardkod
|
||||
background_color: '#08080a', theme_color: '#d4af37', // hardkod kolorów
|
||||
icons: [{ src: '/logo/rcc-logo.svg' }] // statyczna ścieżka, nie panel
|
||||
// ŹLE — hardkod jawny (rcustomcars)
|
||||
let name = 'R Custom Cars'; short_name: 'RCC'
|
||||
background_color: '#08080a', theme_color: '#d4af37'
|
||||
icons: [{ src: '/logo/rcc-logo.svg' }] // statyczna ścieżka
|
||||
|
||||
// ŹLE — hardkod UKRYTY w fallbacku (kancelaria)
|
||||
siteName || 'Kancelaria Adwokacka Adwokat Romuald Kędzierski' // cudza nazwa w ||
|
||||
sizes: 'any', type: mimeType // 'any' na PNG = źle
|
||||
catch { return { name: 'Kancelaria...', theme_color: '#0e1e24' } } // hardkod w catch
|
||||
```
|
||||
|
||||
Fallback `|| 'Nazwa Klienta'` wygląda niewinnie, ale to hardkod — inny projekt
|
||||
skopiuje i pokaże cudzą nazwę, gdy panel zawiedzie. Brak danych → pomiń pole.
|
||||
|
||||
Jeśli klient potrzebuje kolorów motywu / opisu w manifeście — **dodaj pola**
|
||||
`themeColor`, `manifestDescription` w SiteSettings (SiteSettingsFields przez
|
||||
opcje pluginu) i czytaj z panelu. Wtedy redaktor je zmienia, i nie są zaszyte.
|
||||
`themeColor`, `manifestDescription` w SiteSettings (przez opcje pluginu
|
||||
SiteSettingsFields) i czytaj z panelu. Wtedy redaktor je zmienia, nie są zaszyte.
|
||||
|
||||
### Inne ręczne rozszerzenia — ta sama zasada
|
||||
|
||||
@@ -409,6 +457,55 @@ typu, itp.):
|
||||
- jeśli to uniwersalne i powtarzalne → rozważ zgłoszenie do pluginu zamiast
|
||||
ręcznie (patrz ANTIGRAVITY-ZASADY-AGENT.md A0)
|
||||
|
||||
## KRYTYCZNE: metadata w <head> dla Google (htmlLimitedBots)
|
||||
|
||||
**Największa pułapka SEO w Next.js — dotyczy KAŻDEGO projektu.** Dla dynamicznie
|
||||
renderowanych stron (SSR) Next.js **streamuje metadata do `<body>`**, nie `<head>`,
|
||||
i przenosi ją do head skryptem JS. Skutek: canonical, hreflang, title, favicon
|
||||
lądują w body w surowym HTML. Crawlery, które nie wykonują JS (Screaming Frog,
|
||||
część botów), widzą je poza head → ignorują → utrata SEO.
|
||||
|
||||
Google *twierdzi*, że wykonuje JS i widzi przeniesione tagi, ale praktyka
|
||||
(i audyty) pokazują realne problemy z indeksacją canonical. Bezpieczniej wymusić
|
||||
metadata do head dla crawlerów.
|
||||
|
||||
### Rozwiązanie — htmlLimitedBots w next.config
|
||||
|
||||
```ts
|
||||
// next.config.ts
|
||||
const nextConfig: NextConfig = {
|
||||
// Wymusza blocking metadata (canonical, hreflang, title, favicon) w <head>
|
||||
// dla crawlerów SEO — zamiast streamingu do <body>.
|
||||
htmlLimitedBots:
|
||||
/Googlebot|Google-InspectionTool|Storebot-Google|Bingbot|Yandex|DuckDuckBot|Baiduspider|Screaming Frog|AhrefsBot|SemrushBot/i,
|
||||
// ...reszta
|
||||
}
|
||||
```
|
||||
|
||||
`htmlLimitedBots` mówi Next: dla tych User-Agentów wyłącz streaming, wstaw
|
||||
metadata do `<head>` w surowym HTML (blocking). Użytkownicy dalej dostają
|
||||
streaming (szybkie ładowanie); crawlery dostają poprawny head.
|
||||
|
||||
### Objawy (że masz ten problem)
|
||||
|
||||
- Screaming Frog: „canonical/hreflang/title outside <head>"
|
||||
- Search Console: „brak canonical", favicon nie pokazuje się (glob)
|
||||
- W surowym HTML canonical/title są PO `</head>`, na końcu body, ze skryptem
|
||||
`document.querySelectorAll('body link[rel=icon]')...appendChild`
|
||||
|
||||
### Weryfikacja
|
||||
|
||||
```bash
|
||||
# jako Googlebot — metadata MUSI być w <head>
|
||||
curl -A "Googlebot" https://twojadomena.pl/pl/strona | grep -o '<head>.*</head>' | grep canonical
|
||||
# jako user — streaming (metadata w body — OK dla ludzi wykonujących JS)
|
||||
curl -A "Mozilla/5.0" https://twojadomena.pl/pl/strona
|
||||
```
|
||||
|
||||
Bez htmlLimitedBots ten sam problem dotknie favicon (glob w Google), canonical
|
||||
(„User-declared canonical: None"), hreflang i title. Jedna linia w config
|
||||
naprawia wszystko naraz.
|
||||
|
||||
## SEO wielojęzyczne — hreflang, x-default, redirect roota
|
||||
|
||||
Przekierowanie `/` → `/pl` (negocjacja locale) może wpływać na SEO. Kluczowe:
|
||||
@@ -537,3 +634,80 @@ Plugin dostarcza helpery — projekt MUSI je wpiąć i podać dane z panelu:
|
||||
- [ ] Logiczna hierarchia + linkowanie wewnętrzne z głównej
|
||||
|
||||
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">`.
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@intecion/ipal-kit",
|
||||
"version": "1.1.2",
|
||||
"version": "1.2.2",
|
||||
"description": "Intecion Payload Advanced Library — a Payload CMS 3 plugin: i18n, SEO, forms, consent, analytics, blog/archives.",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -100,8 +100,11 @@ export {
|
||||
export { buildSecurityHeaders } from './modules/security/index.js'
|
||||
export type { BuildSecurityHeadersArgs, SecurityHeader } from './modules/security/index.js'
|
||||
export {
|
||||
buildFaqJsonLd,
|
||||
buildIconsMetadata,
|
||||
buildLocalBusinessJsonLd,
|
||||
buildOrganizationJsonLd,
|
||||
buildServiceJsonLd,
|
||||
validateFaviconField,
|
||||
} from './modules/seo/index.js'
|
||||
// Structured data (schema.org JSON-LD) — brand/sitelink signals for Google.
|
||||
|
||||
@@ -1,22 +1,14 @@
|
||||
import type { BasePayload, SanitizedConfig } from 'payload'
|
||||
|
||||
import { getPayload } from 'payload'
|
||||
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 { RobotsRules, SitemapEntry } from '../seo/index.js'
|
||||
|
||||
import { getArchiveEntries, resolveRoute as resolveRouteRaw } from '../content/index.js'
|
||||
import { buildRobots, buildSitemapEntries } from '../seo/index.js'
|
||||
import type { SitemapEntry, RobotsRules } from '../seo/index.js'
|
||||
import { buildSitemapEntries, buildRobots } from '../seo/index.js'
|
||||
|
||||
type CreateContentHelpersArgs = {
|
||||
/**
|
||||
* Absolute site origin for sitemap/robots URLs. Falls back to
|
||||
* NEXT_PUBLIC_SERVER_URL, then to a relative origin (which most crawlers
|
||||
* reject, so set one in production).
|
||||
*/
|
||||
baseUrl?: string
|
||||
/**
|
||||
* The client's payload config promise (the default export of payload.config).
|
||||
* Passed in because the plugin never imports the client's config directly.
|
||||
@@ -24,15 +16,21 @@ type CreateContentHelpersArgs = {
|
||||
config: Promise<SanitizedConfig> | SanitizedConfig
|
||||
/** Archive-backed collections, same value as the plugin option. */
|
||||
content?: ContentOption
|
||||
/** SiteSettings global slug. Defaults to 'site-settings'. */
|
||||
settingsSlug?: string
|
||||
/** Pages collection slug. Defaults to 'pages'. */
|
||||
pagesSlug?: string
|
||||
/**
|
||||
* i18n config. Required only if you want the ready-made `sitemap` / `robots`
|
||||
* handlers — they need the locale list to emit hreflang.
|
||||
*/
|
||||
i18n?: I18nConfig
|
||||
/** Pages collection slug. Defaults to 'pages'. */
|
||||
pagesSlug?: string
|
||||
/** SiteSettings global slug. Defaults to 'site-settings'. */
|
||||
settingsSlug?: string
|
||||
/**
|
||||
* Absolute site origin for sitemap/robots URLs. Falls back to
|
||||
* NEXT_PUBLIC_SERVER_URL, then to a relative origin (which most crawlers
|
||||
* reject, so set one in production).
|
||||
*/
|
||||
baseUrl?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -62,12 +60,12 @@ type CreateContentHelpersArgs = {
|
||||
* 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',
|
||||
pagesSlug = 'pages',
|
||||
i18n,
|
||||
baseUrl,
|
||||
}: CreateContentHelpersArgs) {
|
||||
const origin = baseUrl ?? process.env.NEXT_PUBLIC_SERVER_URL ?? ''
|
||||
const getCachedPayload = cache(async (): Promise<BasePayload> =>
|
||||
@@ -81,7 +79,7 @@ export function createContentHelpers({
|
||||
|
||||
const getSettings = cache(async (locale: string) => {
|
||||
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. */
|
||||
@@ -90,9 +88,9 @@ export function createContentHelpers({
|
||||
locale: string,
|
||||
segments: string[] | undefined,
|
||||
page: number,
|
||||
): Promise<null | ResolvedRoute> => {
|
||||
): Promise<ResolvedRoute | null> => {
|
||||
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,
|
||||
): Promise<ArchiveEntries> => {
|
||||
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
|
||||
* // app/sitemap.ts
|
||||
* export { sitemap as default } from '@/lib/content'
|
||||
* export const dynamic = 'force-dynamic' // generate at runtime, not build
|
||||
* ```
|
||||
*
|
||||
* IMPORTANT — container deploys (Coolify/Docker/Railway/CI): Next treats
|
||||
* sitemap.ts as STATIC by default and prerenders it during `next build`, which
|
||||
* calls into Payload → the database. The build container usually has no access
|
||||
* to the internal Docker network, so the DB connection fails (ENOTFOUND) and
|
||||
* the build dies. Two defenses:
|
||||
* 1. `export const dynamic = 'force-dynamic'` in app/sitemap.ts — skips build
|
||||
* prerender, generates at runtime when the DB is reachable (recommended).
|
||||
* 2. This handler also catches DB errors and returns [] so that even without
|
||||
* (1) the build won't crash — it just ships an empty sitemap until the
|
||||
* next runtime regeneration. (1) is still preferred; (2) is a safety net.
|
||||
*/
|
||||
const sitemap = cache(async (): Promise<SitemapEntry[]> => {
|
||||
if (!i18n) {
|
||||
throw new Error('[ipal] createContentHelpers: pass `i18n` to use the sitemap handler.')
|
||||
}
|
||||
return buildSitemapEntries({
|
||||
baseUrl: origin,
|
||||
config: i18n,
|
||||
content,
|
||||
pagesSlug,
|
||||
payload: await getCachedPayload(),
|
||||
settingsSlug,
|
||||
})
|
||||
try {
|
||||
return await buildSitemapEntries({
|
||||
payload: await getCachedPayload(),
|
||||
config: i18n,
|
||||
baseUrl: origin,
|
||||
content,
|
||||
pagesSlug,
|
||||
settingsSlug,
|
||||
})
|
||||
} catch (error) {
|
||||
// DB unreachable (typically a container build with no DB network) — return
|
||||
// an empty sitemap instead of failing the build. Runtime regeneration will
|
||||
// produce the real one once the DB is reachable. See dynamic='force-dynamic'.
|
||||
console.warn(
|
||||
'[ipal] sitemap: could not reach the database, returning empty entries ' +
|
||||
"(add `export const dynamic = 'force-dynamic'` to app/sitemap.ts to " +
|
||||
'generate at runtime and avoid build-time DB access):',
|
||||
error,
|
||||
)
|
||||
return []
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -145,10 +168,10 @@ export function createContentHelpers({
|
||||
return {
|
||||
getCachedPayload,
|
||||
getConfiguredLocales,
|
||||
getEntries,
|
||||
getSettings,
|
||||
resolveRoute,
|
||||
robots,
|
||||
getEntries,
|
||||
sitemap,
|
||||
robots,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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
|
||||
title: string
|
||||
}
|
||||
/** robots directives — set to noindex/follow for legal/thin/search pages. */
|
||||
robots?: {
|
||||
follow: boolean
|
||||
index: boolean
|
||||
}
|
||||
title: string
|
||||
}
|
||||
|
||||
@@ -39,6 +44,13 @@ type BuildMetadataArgs = {
|
||||
meta?: null | SeoMeta
|
||||
/** Page title or site name first. Defaults to 'page-first'. */
|
||||
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).
|
||||
* Feeds both canonical and hreflang, so /pl/artykuly/moj-post and
|
||||
@@ -78,16 +90,21 @@ export function buildMetadata({
|
||||
locale,
|
||||
meta,
|
||||
order,
|
||||
pageTitle,
|
||||
prefix,
|
||||
query,
|
||||
separator,
|
||||
siteName,
|
||||
slugs,
|
||||
}: BuildMetadataArgs): PageMetadata {
|
||||
// titleOverride wins outright: an editor who filled it in wants that exact
|
||||
// string in the tab, not a composition.
|
||||
// Title source priority: titleOverride (exact, wins outright) > meta.title
|
||||
// (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 title = override || composeTitle({ order, pageTitle: meta?.title, separator, siteName })
|
||||
const resolvedPageTitle = meta?.title?.trim() || pageTitle?.trim() || undefined
|
||||
const title =
|
||||
override || composeTitle({ order, pageTitle: resolvedPageTitle, separator, siteName })
|
||||
const description = meta?.description?.trim() || undefined
|
||||
const origin = baseUrl?.replace(/\/$/, '') ?? ''
|
||||
|
||||
@@ -118,5 +135,8 @@ export function buildMetadata({
|
||||
...(images && { images }),
|
||||
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 } : {}),
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
import type { BasePayload } from 'payload'
|
||||
|
||||
import type { ContentOption } from '../content/index.js'
|
||||
import type { I18nConfig } from '../i18n/index.js'
|
||||
|
||||
import { archiveFieldName } from '../content/index.js'
|
||||
import { buildLocalizedPath, getLocalizedSlugs } from '../i18n/index.js'
|
||||
import { getLocalizedSlugs } from '../i18n/index.js'
|
||||
import { buildLocalizedPath } from '../i18n/index.js'
|
||||
import { buildHreflangAlternates } from './hreflang.js'
|
||||
import type { ContentOption } from '../content/index.js'
|
||||
import { archiveFieldName } from '../content/index.js'
|
||||
|
||||
/**
|
||||
* One sitemap entry, shaped for Next's `app/sitemap.ts`.
|
||||
@@ -16,50 +15,70 @@ import { buildHreflangAlternates } from './hreflang.js'
|
||||
* is the common, weaker kind.
|
||||
*/
|
||||
export type SitemapEntry = {
|
||||
alternates?: { languages: Record<string, string> }
|
||||
changeFrequency?: 'always' | 'daily' | 'hourly' | 'monthly' | 'never' | 'weekly' | 'yearly'
|
||||
lastModified?: Date | string
|
||||
priority?: number
|
||||
url: string
|
||||
lastModified?: string | Date
|
||||
changeFrequency?: 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never'
|
||||
priority?: number
|
||||
alternates?: { languages: Record<string, string> }
|
||||
}
|
||||
|
||||
type CollectionEntry = {
|
||||
slug: string
|
||||
/** Localized segment for entries (archive page slugs), when applicable. */
|
||||
prefixSlugs?: Record<string, string>
|
||||
slug: string
|
||||
}
|
||||
|
||||
type BuildSitemapArgs = {
|
||||
payload: BasePayload
|
||||
config: I18nConfig
|
||||
/** Absolute origin, e.g. 'https://example.com'. Required for valid sitemap URLs. */
|
||||
baseUrl: string
|
||||
changeFrequency?: SitemapEntry['changeFrequency']
|
||||
config: I18nConfig
|
||||
/** Pages collection slug. Defaults to 'pages'. */
|
||||
pagesSlug?: string
|
||||
/** Archive-backed collections, same value as the plugin option. */
|
||||
content?: ContentOption
|
||||
/** SiteSettings global slug. Defaults to 'site-settings'. */
|
||||
settingsSlug?: string
|
||||
/**
|
||||
* Slug of the page that is the site root (collapses to /{locale}).
|
||||
* Read from System Pages when omitted.
|
||||
*/
|
||||
homeSlug?: string
|
||||
/** Pages collection slug. Defaults to 'pages'. */
|
||||
pagesSlug?: string
|
||||
payload: BasePayload
|
||||
/** SiteSettings global slug. Defaults to 'site-settings'. */
|
||||
settingsSlug?: string
|
||||
changeFrequency?: SitemapEntry['changeFrequency']
|
||||
}
|
||||
|
||||
type DocRow = {
|
||||
_status?: string
|
||||
id: number | string
|
||||
meta?: { noindex?: boolean } | null
|
||||
id: string | number
|
||||
slug?: unknown
|
||||
updatedAt?: string
|
||||
_status?: string
|
||||
meta?: { noindex?: boolean } | null
|
||||
}
|
||||
|
||||
/** 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', 'not-found', 'error'])
|
||||
|
||||
/** 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 {
|
||||
if (doc._status && doc._status !== 'published') {return false}
|
||||
if (doc.meta?.noindex) {return false}
|
||||
if (doc._status && doc._status !== 'published') return false
|
||||
if (doc.meta?.noindex) return false
|
||||
if (hasExcludedSlug(doc.slug)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -77,17 +96,17 @@ function entryFor(
|
||||
homeSlug: string | undefined,
|
||||
prefix: Record<string, string> | undefined,
|
||||
changeFrequency: SitemapEntry['changeFrequency'],
|
||||
): null | SitemapEntry {
|
||||
): SitemapEntry | null {
|
||||
const slugs =
|
||||
doc.slug && typeof doc.slug === 'object'
|
||||
? getLocalizedSlugs({ config, slugField: doc.slug as Record<string, unknown> })
|
||||
? getLocalizedSlugs({ slugField: doc.slug as Record<string, unknown>, config })
|
||||
: {}
|
||||
|
||||
const path = buildLocalizedPath({ config, homeSlug, locale, prefix, slugs })
|
||||
if (!path) {return null}
|
||||
const path = buildLocalizedPath({ slugs, locale, config, homeSlug, prefix })
|
||||
if (!path) return null
|
||||
|
||||
const origin = baseUrl.replace(/\/$/, '')
|
||||
const languages = buildHreflangAlternates({ baseUrl, config, homeSlug, prefix, slugs })
|
||||
const languages = buildHreflangAlternates({ slugs, config, baseUrl, homeSlug, prefix })
|
||||
|
||||
return {
|
||||
url: `${origin}${path}`,
|
||||
@@ -119,14 +138,14 @@ function entryFor(
|
||||
* ```
|
||||
*/
|
||||
export async function buildSitemapEntries({
|
||||
baseUrl,
|
||||
changeFrequency = 'weekly',
|
||||
config,
|
||||
content,
|
||||
homeSlug,
|
||||
pagesSlug = 'pages',
|
||||
payload,
|
||||
config,
|
||||
baseUrl,
|
||||
pagesSlug = 'pages',
|
||||
content,
|
||||
settingsSlug = 'site-settings',
|
||||
homeSlug,
|
||||
changeFrequency = 'weekly',
|
||||
}: BuildSitemapArgs): Promise<SitemapEntry[]> {
|
||||
const locales = config.locales.map((l) => l.code)
|
||||
const defaultLocale = config.defaultLocale
|
||||
@@ -135,8 +154,8 @@ export async function buildSitemapEntries({
|
||||
// locales so archive prefixes are available per language).
|
||||
const settings = (await payload.findGlobal({
|
||||
slug: settingsSlug as never,
|
||||
depth: 1,
|
||||
locale: 'all' as never,
|
||||
depth: 1,
|
||||
})) as Record<string, unknown>
|
||||
|
||||
const resolvedHomeSlug =
|
||||
@@ -159,14 +178,14 @@ export async function buildSitemapEntries({
|
||||
// alternates without re-querying per locale.
|
||||
const result = await payload.find({
|
||||
collection: collection.slug as never,
|
||||
locale: 'all' as never,
|
||||
depth: 0,
|
||||
limit: 0, // no pagination — sitemap wants everything
|
||||
locale: 'all' as never,
|
||||
pagination: false as never,
|
||||
})
|
||||
|
||||
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.
|
||||
const entry = entryFor(
|
||||
@@ -178,7 +197,7 @@ export async function buildSitemapEntries({
|
||||
collection.prefixSlugs,
|
||||
changeFrequency,
|
||||
)
|
||||
if (entry) {entries.push(entry)}
|
||||
if (entry) entries.push(entry)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,9 +206,9 @@ export async function buildSitemapEntries({
|
||||
|
||||
/** Pulls a slug string from a populated relationship in a specific locale. */
|
||||
function extractSlugInLocale(rel: unknown, locale: string): string | undefined {
|
||||
if (!rel || typeof rel !== 'object') {return undefined}
|
||||
if (!rel || typeof rel !== 'object') return undefined
|
||||
const slug = (rel as { slug?: unknown }).slug
|
||||
if (typeof slug === 'string') {return slug}
|
||||
if (typeof slug === 'string') return slug
|
||||
if (slug && typeof slug === 'object') {
|
||||
const v = (slug as Record<string, unknown>)[locale]
|
||||
return typeof v === 'string' ? v : undefined
|
||||
@@ -199,14 +218,14 @@ function extractSlugInLocale(rel: unknown, locale: string): string | undefined {
|
||||
|
||||
/** Builds a locale→slug map from a populated archive relationship. */
|
||||
function slugMapAllLocales(rel: unknown, locales: string[]): Record<string, string> | undefined {
|
||||
if (!rel || typeof rel !== 'object') {return undefined}
|
||||
if (!rel || typeof rel !== 'object') return undefined
|
||||
const slug = (rel as { slug?: unknown }).slug
|
||||
if (!slug || typeof slug !== 'object') {return undefined}
|
||||
if (!slug || typeof slug !== 'object') return undefined
|
||||
|
||||
const map: Record<string, string> = {}
|
||||
for (const locale of locales) {
|
||||
const v = (slug as Record<string, unknown>)[locale]
|
||||
if (typeof v === 'string') {map[locale] = v}
|
||||
if (typeof v === 'string') map[locale] = v
|
||||
}
|
||||
return Object.keys(map).length ? map : undefined
|
||||
}
|
||||
|
||||
@@ -43,6 +43,8 @@ type PageMetadataContext = {
|
||||
type DocShape = {
|
||||
id: number | string
|
||||
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. */
|
||||
@@ -151,6 +153,7 @@ export function createPageMetadata(args: CreatePageMetadataArgs) {
|
||||
...base,
|
||||
imageUrl: resolveOgImage(doc),
|
||||
meta: doc.meta,
|
||||
pageTitle: doc.title,
|
||||
prefix,
|
||||
query,
|
||||
slugs,
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
export { buildAutoFillMetaHook } from './autoFillMeta.js'
|
||||
export type { AutoFillMapping } from './autoFillMeta.js'
|
||||
export { buildBreadcrumbJsonLd } from './buildBreadcrumbJsonLd.js'
|
||||
export { buildFaqJsonLd } from './buildFaqJsonLd.js'
|
||||
export { buildIconsMetadata } from './buildIconsMetadata.js'
|
||||
export { buildLocalBusinessJsonLd } from './buildLocalBusinessJsonLd.js'
|
||||
export { buildMetadata } from './buildMetadata.js'
|
||||
export type { PageMetadata } from './buildMetadata.js'
|
||||
export { buildOrganizationJsonLd } from './buildOrganizationJsonLd.js'
|
||||
export { buildRobots } from './buildRobots.js'
|
||||
export type { RobotsRules } from './buildRobots.js'
|
||||
export { buildServiceJsonLd } from './buildServiceJsonLd.js'
|
||||
export { buildSitemapEntries } from './buildSitemapEntries.js'
|
||||
export type { SitemapEntry } from './buildSitemapEntries.js'
|
||||
export { buildSiteNavigationJsonLd } from './buildSiteNavigationJsonLd.js'
|
||||
|
||||
@@ -33,6 +33,7 @@ export type SeoOption = {
|
||||
export type SeoMeta = {
|
||||
description?: null | string
|
||||
image?: unknown
|
||||
noindex?: boolean | null
|
||||
title?: null | string
|
||||
/** When set, used as the whole title — no site name, no separator. */
|
||||
titleOverride?: null | string
|
||||
|
||||
Reference in New Issue
Block a user