Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41630bb8c5 | ||
|
|
b9430e7ab6 | ||
|
|
f1f808d079 | ||
|
|
a8a632e1b0 | ||
|
|
a695ec7319 | ||
|
|
c6730335ef |
Vendored
+1
@@ -7,3 +7,4 @@
|
|||||||
*/
|
*/
|
||||||
export { RenderBlocks } from '../modules/blocks/index.js';
|
export { RenderBlocks } from '../modules/blocks/index.js';
|
||||||
export type { BlockComponentMap, BlockData, EnhanceProps, RenderBlocksProps, } from '../modules/blocks/index.js';
|
export type { BlockComponentMap, BlockData, EnhanceProps, RenderBlocksProps, } from '../modules/blocks/index.js';
|
||||||
|
export { MediaPreconnect } from '../modules/storage/MediaPreconnect.js';
|
||||||
|
|||||||
Vendored
+1
@@ -5,5 +5,6 @@
|
|||||||
* lives here rather than in the main package entry to keep React out of the
|
* lives here rather than in the main package entry to keep React out of the
|
||||||
* server-config bundle.
|
* server-config bundle.
|
||||||
*/ export { RenderBlocks } from '../modules/blocks/index.js';
|
*/ export { RenderBlocks } from '../modules/blocks/index.js';
|
||||||
|
export { MediaPreconnect } from '../modules/storage/MediaPreconnect.js';
|
||||||
|
|
||||||
//# sourceMappingURL=rsc.js.map
|
//# sourceMappingURL=rsc.js.map
|
||||||
Vendored
+1
-1
@@ -1 +1 @@
|
|||||||
{"version":3,"sources":["../../src/exports/rsc.ts"],"sourcesContent":["/**\n * Entry point: ipal-kit/rsc\n *\n * Server-component exports. RenderBlocks is a React Server Component, so it\n * lives here rather than in the main package entry to keep React out of the\n * server-config bundle.\n */\nexport { RenderBlocks } from '../modules/blocks/index.js'\nexport type {\n BlockComponentMap,\n BlockData,\n EnhanceProps,\n RenderBlocksProps,\n} from '../modules/blocks/index.js'\n"],"names":["RenderBlocks"],"mappings":"AAAA;;;;;;CAMC,GACD,SAASA,YAAY,QAAQ,6BAA4B"}
|
{"version":3,"sources":["../../src/exports/rsc.ts"],"sourcesContent":["/**\n * Entry point: ipal-kit/rsc\n *\n * Server-component exports. RenderBlocks is a React Server Component, so it\n * lives here rather than in the main package entry to keep React out of the\n * server-config bundle.\n */\nexport { RenderBlocks } from '../modules/blocks/index.js'\nexport type {\n BlockComponentMap,\n BlockData,\n EnhanceProps,\n RenderBlocksProps,\n} from '../modules/blocks/index.js'\nexport { MediaPreconnect } from '../modules/storage/MediaPreconnect.js'\n"],"names":["RenderBlocks","MediaPreconnect"],"mappings":"AAAA;;;;;;CAMC,GACD,SAASA,YAAY,QAAQ,6BAA4B;AAOzD,SAASC,eAAe,QAAQ,wCAAuC"}
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
import type { Field } from 'payload';
|
import type { Field } from 'payload';
|
||||||
/**
|
/**
|
||||||
* General site identity fields.
|
* General site identity fields.
|
||||||
* Consumed by SEO/OG (siteName, defaultShareImage) and frontend (logo).
|
* Consumed by SEO/OG (siteName, defaultShareImage) and frontend (logo, favicon).
|
||||||
*/
|
*/
|
||||||
export declare const generalFields: Field[];
|
export declare const generalFields: Field[];
|
||||||
|
|||||||
+11
-3
@@ -1,6 +1,7 @@
|
|||||||
|
import { validateFaviconField } from '../../../modules/seo/index.js';
|
||||||
/**
|
/**
|
||||||
* General site identity fields.
|
* General site identity fields.
|
||||||
* Consumed by SEO/OG (siteName, defaultShareImage) and frontend (logo).
|
* Consumed by SEO/OG (siteName, defaultShareImage) and frontend (logo, favicon).
|
||||||
*/ export const generalFields = [
|
*/ export const generalFields = [
|
||||||
{
|
{
|
||||||
name: 'siteName',
|
name: 'siteName',
|
||||||
@@ -63,7 +64,7 @@
|
|||||||
name: 'logo',
|
name: 'logo',
|
||||||
type: 'upload',
|
type: 'upload',
|
||||||
admin: {
|
admin: {
|
||||||
description: 'Primary site logo.'
|
description: 'Primary site logo. Also used for Organization structured data (buildOrganizationJsonLd).'
|
||||||
},
|
},
|
||||||
relationTo: 'media'
|
relationTo: 'media'
|
||||||
},
|
},
|
||||||
@@ -79,7 +80,14 @@
|
|||||||
name: 'favicon',
|
name: 'favicon',
|
||||||
type: 'upload',
|
type: 'upload',
|
||||||
admin: {
|
admin: {
|
||||||
description: 'Square source icon (PNG or SVG) for the browser tab. Rendered by the frontend.'
|
description: 'Square icon, PNG or SVG, min. 48×48px (Google requires this to show it in search). Rendered via buildIconsMetadata.'
|
||||||
|
},
|
||||||
|
hooks: {
|
||||||
|
// Warns the editor at save time if the favicon is too small (<48×48) or
|
||||||
|
// not square — Google won't display such favicons in search results.
|
||||||
|
beforeValidate: [
|
||||||
|
validateFaviconField
|
||||||
|
]
|
||||||
},
|
},
|
||||||
relationTo: 'media'
|
relationTo: 'media'
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
{"version":3,"sources":["../../../../src/globals/SiteSettings/fields/general.ts"],"sourcesContent":["import type { Field } from 'payload'\n\n/**\n * General site identity fields.\n * Consumed by SEO/OG (siteName, defaultShareImage) and frontend (logo).\n */\nexport const generalFields: Field[] = [\n {\n name: 'siteName',\n type: 'text',\n admin: {\n description: 'Used in page titles and Open Graph metadata.',\n },\n localized: true,\n required: true,\n },\n {\n name: 'titleOrder',\n type: 'select',\n admin: {\n description: 'Which comes first in browser tabs.',\n },\n defaultValue: 'page-first',\n options: [\n { label: 'Page first — About Us | Acme', value: 'page-first' },\n { label: 'Site first — Acme | About Us', value: 'site-first' },\n ],\n },\n {\n name: 'titleSeparator',\n type: 'select',\n admin: {\n description: 'Separates the page title from the site name in browser tabs.',\n },\n defaultValue: '|',\n options: [\n { label: 'Pipe — Page | Site', value: '|' },\n { label: 'Dash — Page – Site', value: '–' },\n { label: 'Hyphen — Page - Site', value: '-' },\n { label: 'Bullet — Page · Site', value: '·' },\n { label: 'Slash — Page / Site', value: '/' },\n ],\n },\n {\n name: 'logo',\n type: 'upload',\n admin: {\n description: 'Primary site logo.',\n },\n relationTo: 'media',\n },\n {\n name: 'defaultShareImage',\n type: 'upload',\n admin: {\n description: 'Fallback Open Graph image when a page has none.',\n },\n relationTo: 'media',\n },\n {\n name: 'favicon',\n type: 'upload',\n admin: {\n description: 'Square source icon (PNG or SVG) for the browser tab. Rendered by the frontend.',\n },\n relationTo: 'media',\n },\n]\n"],"names":["generalFields","name","type","admin","description","localized","required","defaultValue","options","label","value","relationTo"],"mappings":"AAEA;;;CAGC,GACD,OAAO,MAAMA,gBAAyB;IACpC;QACEC,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;QACAC,WAAW;QACXC,UAAU;IACZ;IACA;QACEL,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;QACAG,cAAc;QACdC,SAAS;YACP;gBAAEC,OAAO;gBAAkCC,OAAO;YAAa;YAC/D;gBAAED,OAAO;gBAAkCC,OAAO;YAAa;SAChE;IACH;IACA;QACET,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;QACAG,cAAc;QACdC,SAAS;YACP;gBAAEC,OAAO;gBAAwBC,OAAO;YAAI;YAC5C;gBAAED,OAAO;gBAAwBC,OAAO;YAAI;YAC5C;gBAAED,OAAO;gBAA0BC,OAAO;YAAI;YAC9C;gBAAED,OAAO;gBAA0BC,OAAO;YAAI;YAC9C;gBAAED,OAAO;gBAAyBC,OAAO;YAAI;SAC9C;IACH;IACA;QACET,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;QACAO,YAAY;IACd;IACA;QACEV,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;QACAO,YAAY;IACd;IACA;QACEV,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;QACAO,YAAY;IACd;CACD,CAAA"}
|
{"version":3,"sources":["../../../../src/globals/SiteSettings/fields/general.ts"],"sourcesContent":["import type { Field } from 'payload'\n\nimport { validateFaviconField } from '../../../modules/seo/index.js'\n\n/**\n * General site identity fields.\n * Consumed by SEO/OG (siteName, defaultShareImage) and frontend (logo, favicon).\n */\nexport const generalFields: Field[] = [\n {\n name: 'siteName',\n type: 'text',\n admin: {\n description: 'Used in page titles and Open Graph metadata.',\n },\n localized: true,\n required: true,\n },\n {\n name: 'titleOrder',\n type: 'select',\n admin: {\n description: 'Which comes first in browser tabs.',\n },\n defaultValue: 'page-first',\n options: [\n { label: 'Page first — About Us | Acme', value: 'page-first' },\n { label: 'Site first — Acme | About Us', value: 'site-first' },\n ],\n },\n {\n name: 'titleSeparator',\n type: 'select',\n admin: {\n description: 'Separates the page title from the site name in browser tabs.',\n },\n defaultValue: '|',\n options: [\n { label: 'Pipe — Page | Site', value: '|' },\n { label: 'Dash — Page – Site', value: '–' },\n { label: 'Hyphen — Page - Site', value: '-' },\n { label: 'Bullet — Page · Site', value: '·' },\n { label: 'Slash — Page / Site', value: '/' },\n ],\n },\n {\n name: 'logo',\n type: 'upload',\n admin: {\n description:\n 'Primary site logo. Also used for Organization structured data (buildOrganizationJsonLd).',\n },\n relationTo: 'media',\n },\n {\n name: 'defaultShareImage',\n type: 'upload',\n admin: {\n description: 'Fallback Open Graph image when a page has none.',\n },\n relationTo: 'media',\n },\n {\n name: 'favicon',\n type: 'upload',\n admin: {\n description:\n 'Square icon, PNG or SVG, min. 48×48px (Google requires this to show it in search). Rendered via buildIconsMetadata.',\n },\n hooks: {\n // Warns the editor at save time if the favicon is too small (<48×48) or\n // not square — Google won't display such favicons in search results.\n beforeValidate: [validateFaviconField],\n },\n relationTo: 'media',\n },\n]\n"],"names":["validateFaviconField","generalFields","name","type","admin","description","localized","required","defaultValue","options","label","value","relationTo","hooks","beforeValidate"],"mappings":"AAEA,SAASA,oBAAoB,QAAQ,gCAA+B;AAEpE;;;CAGC,GACD,OAAO,MAAMC,gBAAyB;IACpC;QACEC,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;QACAC,WAAW;QACXC,UAAU;IACZ;IACA;QACEL,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;QACAG,cAAc;QACdC,SAAS;YACP;gBAAEC,OAAO;gBAAkCC,OAAO;YAAa;YAC/D;gBAAED,OAAO;gBAAkCC,OAAO;YAAa;SAChE;IACH;IACA;QACET,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;QACAG,cAAc;QACdC,SAAS;YACP;gBAAEC,OAAO;gBAAwBC,OAAO;YAAI;YAC5C;gBAAED,OAAO;gBAAwBC,OAAO;YAAI;YAC5C;gBAAED,OAAO;gBAA0BC,OAAO;YAAI;YAC9C;gBAAED,OAAO;gBAA0BC,OAAO;YAAI;YAC9C;gBAAED,OAAO;gBAAyBC,OAAO;YAAI;SAC9C;IACH;IACA;QACET,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aACE;QACJ;QACAO,YAAY;IACd;IACA;QACEV,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;QACAO,YAAY;IACd;IACA;QACEV,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aACE;QACJ;QACAQ,OAAO;YACL,wEAAwE;YACxE,qEAAqE;YACrEC,gBAAgB;gBAACd;aAAqB;QACxC;QACAY,YAAY;IACd;CACD,CAAA"}
|
||||||
Vendored
+1
@@ -29,6 +29,7 @@ export type { GlobalQueryOptions } from './modules/payload/index.js';
|
|||||||
export { getGlobal, getSiteIntegrations, getSiteSettings, SITE_INTEGRATIONS_SLUG, SITE_SETTINGS_SLUG, } from './modules/payload/index.js';
|
export { getGlobal, getSiteIntegrations, getSiteSettings, SITE_INTEGRATIONS_SLUG, SITE_SETTINGS_SLUG, } from './modules/payload/index.js';
|
||||||
export { buildSecurityHeaders } from './modules/security/index.js';
|
export { buildSecurityHeaders } from './modules/security/index.js';
|
||||||
export type { BuildSecurityHeadersArgs, SecurityHeader } from './modules/security/index.js';
|
export type { BuildSecurityHeadersArgs, SecurityHeader } from './modules/security/index.js';
|
||||||
|
export { buildIconsMetadata, buildOrganizationJsonLd, validateFaviconField, } from './modules/seo/index.js';
|
||||||
export type { PageMetadata, SeoMeta, SeoOption } from './modules/seo/index.js';
|
export type { PageMetadata, SeoMeta, SeoOption } from './modules/seo/index.js';
|
||||||
export { buildHreflangAlternates, buildMetadata, composeTitle } from './modules/seo/index.js';
|
export { buildHreflangAlternates, buildMetadata, composeTitle } from './modules/seo/index.js';
|
||||||
export type { AutoFillMapping, RobotsRules, SitemapEntry } from './modules/seo/index.js';
|
export type { AutoFillMapping, RobotsRules, SitemapEntry } from './modules/seo/index.js';
|
||||||
|
|||||||
Vendored
+1
@@ -18,6 +18,7 @@ export { getNotificationTexts, NOTIFICATION_FALLBACK, resolveFormMessage } from
|
|||||||
export { ALL_SYSTEM_PAGE_ROLES, getSystemPagePath } from './modules/pages/index.js';
|
export { ALL_SYSTEM_PAGE_ROLES, getSystemPagePath } from './modules/pages/index.js';
|
||||||
export { getGlobal, getSiteIntegrations, getSiteSettings, SITE_INTEGRATIONS_SLUG, SITE_SETTINGS_SLUG } from './modules/payload/index.js';
|
export { getGlobal, getSiteIntegrations, getSiteSettings, SITE_INTEGRATIONS_SLUG, SITE_SETTINGS_SLUG } from './modules/payload/index.js';
|
||||||
export { buildSecurityHeaders } from './modules/security/index.js';
|
export { buildSecurityHeaders } from './modules/security/index.js';
|
||||||
|
export { buildIconsMetadata, buildOrganizationJsonLd, validateFaviconField } from './modules/seo/index.js';
|
||||||
export { buildHreflangAlternates, buildMetadata, composeTitle } from './modules/seo/index.js';
|
export { buildHreflangAlternates, buildMetadata, composeTitle } from './modules/seo/index.js';
|
||||||
export { buildAutoFillMetaHook, buildRobots, buildSitemapEntries, createMetadataGenerator, createPageMetadata, injectAutoFillMeta } from './modules/seo/index.js';
|
export { buildAutoFillMetaHook, buildRobots, buildSitemapEntries, createMetadataGenerator, createPageMetadata, injectAutoFillMeta } from './modules/seo/index.js';
|
||||||
export { buildSlugField, toSlug } from './modules/slug/index.js';
|
export { buildSlugField, toSlug } from './modules/slug/index.js';
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+33
@@ -0,0 +1,33 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
type MediaLike = {
|
||||||
|
height?: null | number;
|
||||||
|
mimeType?: null | string;
|
||||||
|
url?: null | string;
|
||||||
|
width?: null | number;
|
||||||
|
} | null | undefined;
|
||||||
|
/**
|
||||||
|
* Builds Next.js `icons` metadata (favicon / apple-touch-icon) from the panel's
|
||||||
|
* favicon upload, so the browser tab AND Google get a proper <link rel="icon">.
|
||||||
|
*
|
||||||
|
* Why the plugin must do this (not the project): favicon-in-Google has strict
|
||||||
|
* rules — a real <link rel="icon"> in <head>, square, ≥48×48, at a stable URL.
|
||||||
|
* Leaving it to each project meant inconsistent hand-rolled tags and no favicon
|
||||||
|
* in search results. This generates the tags correctly, every time, from the
|
||||||
|
* panel field.
|
||||||
|
*
|
||||||
|
* Favicon is GLOBAL (same across pages), so call this once in the ROOT layout's
|
||||||
|
* generateMetadata — not per page:
|
||||||
|
*
|
||||||
|
* import { buildIconsMetadata } from '@intecion/ipal-kit'
|
||||||
|
* export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
* const settings = await getSettings(locale)
|
||||||
|
* return buildIconsMetadata(settings.favicon)
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* Google notes: it caches favicons separately and slowly (days/weeks), and only
|
||||||
|
* shows them for icons it deems valid. Warn on too-small icons at upload time
|
||||||
|
* (see the media validation hook) so editors don't ship a <48px favicon Google
|
||||||
|
* will reject.
|
||||||
|
*/
|
||||||
|
export declare function buildIconsMetadata(favicon: MediaLike): Metadata;
|
||||||
|
export {};
|
||||||
+54
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* Builds Next.js `icons` metadata (favicon / apple-touch-icon) from the panel's
|
||||||
|
* favicon upload, so the browser tab AND Google get a proper <link rel="icon">.
|
||||||
|
*
|
||||||
|
* Why the plugin must do this (not the project): favicon-in-Google has strict
|
||||||
|
* rules — a real <link rel="icon"> in <head>, square, ≥48×48, at a stable URL.
|
||||||
|
* Leaving it to each project meant inconsistent hand-rolled tags and no favicon
|
||||||
|
* in search results. This generates the tags correctly, every time, from the
|
||||||
|
* panel field.
|
||||||
|
*
|
||||||
|
* Favicon is GLOBAL (same across pages), so call this once in the ROOT layout's
|
||||||
|
* generateMetadata — not per page:
|
||||||
|
*
|
||||||
|
* import { buildIconsMetadata } from '@intecion/ipal-kit'
|
||||||
|
* export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
* const settings = await getSettings(locale)
|
||||||
|
* return buildIconsMetadata(settings.favicon)
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* Google notes: it caches favicons separately and slowly (days/weeks), and only
|
||||||
|
* shows them for icons it deems valid. Warn on too-small icons at upload time
|
||||||
|
* (see the media validation hook) so editors don't ship a <48px favicon Google
|
||||||
|
* will reject.
|
||||||
|
*/ export function buildIconsMetadata(favicon) {
|
||||||
|
const url = favicon?.url;
|
||||||
|
if (!url) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const isSvg = favicon?.mimeType === 'image/svg+xml' || url.endsWith('.svg');
|
||||||
|
return {
|
||||||
|
icons: {
|
||||||
|
// Main favicon. SVG scales; PNG should be ≥48×48 (ideally 96 or 192).
|
||||||
|
icon: isSvg ? [
|
||||||
|
{
|
||||||
|
type: 'image/svg+xml',
|
||||||
|
url
|
||||||
|
}
|
||||||
|
] : [
|
||||||
|
{
|
||||||
|
sizes: 'any',
|
||||||
|
url
|
||||||
|
}
|
||||||
|
],
|
||||||
|
// Apple touch icon (home-screen bookmark on iOS). Reuses the same asset.
|
||||||
|
apple: [
|
||||||
|
{
|
||||||
|
url
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
//# sourceMappingURL=buildIconsMetadata.js.map
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"sources":["../../../src/modules/seo/buildIconsMetadata.ts"],"sourcesContent":["import type { Metadata } from 'next'\n\ntype MediaLike =\n | { height?: null | number; mimeType?: null | string; url?: null | string; width?: null | number }\n | null\n | undefined\n\n/**\n * Builds Next.js `icons` metadata (favicon / apple-touch-icon) from the panel's\n * favicon upload, so the browser tab AND Google get a proper <link rel=\"icon\">.\n *\n * Why the plugin must do this (not the project): favicon-in-Google has strict\n * rules — a real <link rel=\"icon\"> in <head>, square, ≥48×48, at a stable URL.\n * Leaving it to each project meant inconsistent hand-rolled tags and no favicon\n * in search results. This generates the tags correctly, every time, from the\n * panel field.\n *\n * Favicon is GLOBAL (same across pages), so call this once in the ROOT layout's\n * generateMetadata — not per page:\n *\n * import { buildIconsMetadata } from '@intecion/ipal-kit'\n * export async function generateMetadata(): Promise<Metadata> {\n * const settings = await getSettings(locale)\n * return buildIconsMetadata(settings.favicon)\n * }\n *\n * Google notes: it caches favicons separately and slowly (days/weeks), and only\n * shows them for icons it deems valid. Warn on too-small icons at upload time\n * (see the media validation hook) so editors don't ship a <48px favicon Google\n * will reject.\n */\nexport function buildIconsMetadata(favicon: MediaLike): Metadata {\n const url = favicon?.url\n if (!url) {return {}}\n\n const isSvg = favicon?.mimeType === 'image/svg+xml' || url.endsWith('.svg')\n\n return {\n icons: {\n // Main favicon. SVG scales; PNG should be ≥48×48 (ideally 96 or 192).\n icon: isSvg ? [{ type: 'image/svg+xml', url }] : [{ sizes: 'any', url }],\n // Apple touch icon (home-screen bookmark on iOS). Reuses the same asset.\n apple: [{ url }],\n },\n }\n}\n"],"names":["buildIconsMetadata","favicon","url","isSvg","mimeType","endsWith","icons","icon","type","sizes","apple"],"mappings":"AAOA;;;;;;;;;;;;;;;;;;;;;;;CAuBC,GACD,OAAO,SAASA,mBAAmBC,OAAkB;IACnD,MAAMC,MAAMD,SAASC;IACrB,IAAI,CAACA,KAAK;QAAC,OAAO,CAAC;IAAC;IAEpB,MAAMC,QAAQF,SAASG,aAAa,mBAAmBF,IAAIG,QAAQ,CAAC;IAEpE,OAAO;QACLC,OAAO;YACL,sEAAsE;YACtEC,MAAMJ,QAAQ;gBAAC;oBAAEK,MAAM;oBAAiBN;gBAAI;aAAE,GAAG;gBAAC;oBAAEO,OAAO;oBAAOP;gBAAI;aAAE;YACxE,yEAAyE;YACzEQ,OAAO;gBAAC;oBAAER;gBAAI;aAAE;QAClB;IACF;AACF"}
|
||||||
+41
@@ -0,0 +1,41 @@
|
|||||||
|
type MediaLike = {
|
||||||
|
url?: null | string;
|
||||||
|
} | null | undefined;
|
||||||
|
type OrganizationJsonLdArgs = {
|
||||||
|
/** Logo media (from panel). Google uses this for brand knowledge panels. */
|
||||||
|
logo?: MediaLike;
|
||||||
|
/** Organization / site name. */
|
||||||
|
name: string;
|
||||||
|
/** Optional social / official profile URLs (sameAs). */
|
||||||
|
sameAs?: string[];
|
||||||
|
/** Absolute site URL (https://…). */
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Builds Organization JSON-LD (schema.org) — helps Google associate the site
|
||||||
|
* with a brand: name, logo, official links. Improves how the site appears in
|
||||||
|
* search (brand recognition, logo in knowledge panels) and is a signal used
|
||||||
|
* alongside favicon for identity.
|
||||||
|
*
|
||||||
|
* Returns a plain object; the project renders it as a <script type="application/
|
||||||
|
* ld+json"> in the root layout:
|
||||||
|
*
|
||||||
|
* import { buildOrganizationJsonLd } from '@intecion/ipal-kit'
|
||||||
|
* const jsonLd = buildOrganizationJsonLd({
|
||||||
|
* name: settings.siteName, url: baseUrl, logo: settings.logo,
|
||||||
|
* })
|
||||||
|
* <script type="application/ld+json"
|
||||||
|
* dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
|
||||||
|
*
|
||||||
|
* Data comes from the panel (siteName, logo) — nothing hardcoded. Emit once in
|
||||||
|
* the root layout (Organization is site-wide, not per page).
|
||||||
|
*/
|
||||||
|
export declare function buildOrganizationJsonLd({ name, logo, sameAs, url }: OrganizationJsonLdArgs): {
|
||||||
|
sameAs?: string[] | undefined;
|
||||||
|
logo?: string | undefined;
|
||||||
|
name: string;
|
||||||
|
'@context': string;
|
||||||
|
'@type': string;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
export {};
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* Builds Organization JSON-LD (schema.org) — helps Google associate the site
|
||||||
|
* with a brand: name, logo, official links. Improves how the site appears in
|
||||||
|
* search (brand recognition, logo in knowledge panels) and is a signal used
|
||||||
|
* alongside favicon for identity.
|
||||||
|
*
|
||||||
|
* Returns a plain object; the project renders it as a <script type="application/
|
||||||
|
* ld+json"> in the root layout:
|
||||||
|
*
|
||||||
|
* import { buildOrganizationJsonLd } from '@intecion/ipal-kit'
|
||||||
|
* const jsonLd = buildOrganizationJsonLd({
|
||||||
|
* name: settings.siteName, url: baseUrl, logo: settings.logo,
|
||||||
|
* })
|
||||||
|
* <script type="application/ld+json"
|
||||||
|
* dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
|
||||||
|
*
|
||||||
|
* Data comes from the panel (siteName, logo) — nothing hardcoded. Emit once in
|
||||||
|
* the root layout (Organization is site-wide, not per page).
|
||||||
|
*/ export function buildOrganizationJsonLd({ name, logo, sameAs, url }) {
|
||||||
|
const logoUrl = logo?.url;
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'Organization',
|
||||||
|
url,
|
||||||
|
...logoUrl ? {
|
||||||
|
logo: logoUrl.startsWith('http') ? logoUrl : `${url}${logoUrl}`
|
||||||
|
} : {},
|
||||||
|
...sameAs && sameAs.length > 0 ? {
|
||||||
|
sameAs
|
||||||
|
} : {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
//# sourceMappingURL=buildOrganizationJsonLd.js.map
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"sources":["../../../src/modules/seo/buildOrganizationJsonLd.ts"],"sourcesContent":["type MediaLike = { url?: null | string } | null | undefined\n\ntype OrganizationJsonLdArgs = {\n /** Logo media (from panel). Google uses this for brand knowledge panels. */\n logo?: MediaLike\n /** Organization / site name. */\n name: string\n /** Optional social / official profile URLs (sameAs). */\n sameAs?: string[]\n /** Absolute site URL (https://…). */\n url: string\n}\n\n/**\n * Builds Organization JSON-LD (schema.org) — helps Google associate the site\n * with a brand: name, logo, official links. Improves how the site appears in\n * search (brand recognition, logo in knowledge panels) and is a signal used\n * alongside favicon for identity.\n *\n * Returns a plain object; the project renders it as a <script type=\"application/\n * ld+json\"> in the root layout:\n *\n * import { buildOrganizationJsonLd } from '@intecion/ipal-kit'\n * const jsonLd = buildOrganizationJsonLd({\n * name: settings.siteName, url: baseUrl, logo: settings.logo,\n * })\n * <script type=\"application/ld+json\"\n * dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />\n *\n * Data comes from the panel (siteName, logo) — nothing hardcoded. Emit once in\n * the root layout (Organization is site-wide, not per page).\n */\nexport function buildOrganizationJsonLd({ name, logo, sameAs, url }: OrganizationJsonLdArgs) {\n const logoUrl = logo?.url\n\n return {\n name,\n '@context': 'https://schema.org',\n '@type': 'Organization',\n url,\n ...(logoUrl ? { logo: logoUrl.startsWith('http') ? logoUrl : `${url}${logoUrl}` } : {}),\n ...(sameAs && sameAs.length > 0 ? { sameAs } : {}),\n }\n}\n"],"names":["buildOrganizationJsonLd","name","logo","sameAs","url","logoUrl","startsWith","length"],"mappings":"AAaA;;;;;;;;;;;;;;;;;;CAkBC,GACD,OAAO,SAASA,wBAAwB,EAAEC,IAAI,EAAEC,IAAI,EAAEC,MAAM,EAAEC,GAAG,EAA0B;IACzF,MAAMC,UAAUH,MAAME;IAEtB,OAAO;QACLH;QACA,YAAY;QACZ,SAAS;QACTG;QACA,GAAIC,UAAU;YAAEH,MAAMG,QAAQC,UAAU,CAAC,UAAUD,UAAU,GAAGD,MAAMC,SAAS;QAAC,IAAI,CAAC,CAAC;QACtF,GAAIF,UAAUA,OAAOI,MAAM,GAAG,IAAI;YAAEJ;QAAO,IAAI,CAAC,CAAC;IACnD;AACF"}
|
||||||
Vendored
+3
@@ -1,7 +1,9 @@
|
|||||||
export { buildAutoFillMetaHook } from './autoFillMeta.js';
|
export { buildAutoFillMetaHook } from './autoFillMeta.js';
|
||||||
export type { AutoFillMapping } from './autoFillMeta.js';
|
export type { AutoFillMapping } from './autoFillMeta.js';
|
||||||
|
export { buildIconsMetadata } from './buildIconsMetadata.js';
|
||||||
export { buildMetadata } from './buildMetadata.js';
|
export { buildMetadata } from './buildMetadata.js';
|
||||||
export type { PageMetadata } from './buildMetadata.js';
|
export type { PageMetadata } from './buildMetadata.js';
|
||||||
|
export { buildOrganizationJsonLd } from './buildOrganizationJsonLd.js';
|
||||||
export { buildRobots } from './buildRobots.js';
|
export { buildRobots } from './buildRobots.js';
|
||||||
export type { RobotsRules } from './buildRobots.js';
|
export type { RobotsRules } from './buildRobots.js';
|
||||||
export { buildSitemapEntries } from './buildSitemapEntries.js';
|
export { buildSitemapEntries } from './buildSitemapEntries.js';
|
||||||
@@ -18,3 +20,4 @@ export type { SiteMetaConfig } from './readSiteMetaConfig.js';
|
|||||||
export { buildSeoPlugin } from './seoPluginConfig.js';
|
export { buildSeoPlugin } from './seoPluginConfig.js';
|
||||||
export { slugsAcrossLocales } from './slugsAcrossLocales.js';
|
export { slugsAcrossLocales } from './slugsAcrossLocales.js';
|
||||||
export type { SeoMeta, SeoOption } from './types.js';
|
export type { SeoMeta, SeoOption } from './types.js';
|
||||||
|
export { validateFaviconField } from './validateFavicon.js';
|
||||||
|
|||||||
Vendored
+3
@@ -1,5 +1,7 @@
|
|||||||
export { buildAutoFillMetaHook } from './autoFillMeta.js';
|
export { buildAutoFillMetaHook } from './autoFillMeta.js';
|
||||||
|
export { buildIconsMetadata } from './buildIconsMetadata.js';
|
||||||
export { buildMetadata } from './buildMetadata.js';
|
export { buildMetadata } from './buildMetadata.js';
|
||||||
|
export { buildOrganizationJsonLd } from './buildOrganizationJsonLd.js';
|
||||||
export { buildRobots } from './buildRobots.js';
|
export { buildRobots } from './buildRobots.js';
|
||||||
export { buildSitemapEntries } from './buildSitemapEntries.js';
|
export { buildSitemapEntries } from './buildSitemapEntries.js';
|
||||||
export { composeTitle } from './composeTitle.js';
|
export { composeTitle } from './composeTitle.js';
|
||||||
@@ -11,5 +13,6 @@ export { injectSeoTabs } from './injectSeoTabs.js';
|
|||||||
export { readSiteMetaConfig } from './readSiteMetaConfig.js';
|
export { readSiteMetaConfig } from './readSiteMetaConfig.js';
|
||||||
export { buildSeoPlugin } from './seoPluginConfig.js';
|
export { buildSeoPlugin } from './seoPluginConfig.js';
|
||||||
export { slugsAcrossLocales } from './slugsAcrossLocales.js';
|
export { slugsAcrossLocales } from './slugsAcrossLocales.js';
|
||||||
|
export { validateFaviconField } from './validateFavicon.js';
|
||||||
|
|
||||||
//# sourceMappingURL=index.js.map
|
//# sourceMappingURL=index.js.map
|
||||||
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 { buildMetadata } from './buildMetadata.js'\nexport type { PageMetadata } from './buildMetadata.js'\nexport { buildRobots } from './buildRobots.js'\nexport type { RobotsRules } from './buildRobots.js'\nexport { buildSitemapEntries } from './buildSitemapEntries.js'\nexport type { SitemapEntry } from './buildSitemapEntries.js'\nexport { composeTitle } from './composeTitle.js'\nexport type { TitleOrder } from './composeTitle.js'\nexport { createMetadataGenerator } from './createMetadataGenerator.js'\nexport { createPageMetadata } from './createPageMetadata.js'\nexport { buildHreflangAlternates } from './hreflang.js'\nexport { injectAutoFillMeta } from './injectAutoFillMeta.js'\nexport { injectSeoTabs } from './injectSeoTabs.js'\nexport { readSiteMetaConfig } from './readSiteMetaConfig.js'\nexport type { SiteMetaConfig } from './readSiteMetaConfig.js'\nexport { buildSeoPlugin } from './seoPluginConfig.js'\nexport { slugsAcrossLocales } from './slugsAcrossLocales.js'\nexport type { SeoMeta, SeoOption } from './types.js'\n"],"names":["buildAutoFillMetaHook","buildMetadata","buildRobots","buildSitemapEntries","composeTitle","createMetadataGenerator","createPageMetadata","buildHreflangAlternates","injectAutoFillMeta","injectSeoTabs","readSiteMetaConfig","buildSeoPlugin","slugsAcrossLocales"],"mappings":"AAAA,SAASA,qBAAqB,QAAQ,oBAAmB;AAEzD,SAASC,aAAa,QAAQ,qBAAoB;AAElD,SAASC,WAAW,QAAQ,mBAAkB;AAE9C,SAASC,mBAAmB,QAAQ,2BAA0B;AAE9D,SAASC,YAAY,QAAQ,oBAAmB;AAEhD,SAASC,uBAAuB,QAAQ,+BAA8B;AACtE,SAASC,kBAAkB,QAAQ,0BAAyB;AAC5D,SAASC,uBAAuB,QAAQ,gBAAe;AACvD,SAASC,kBAAkB,QAAQ,0BAAyB;AAC5D,SAASC,aAAa,QAAQ,qBAAoB;AAClD,SAASC,kBAAkB,QAAQ,0BAAyB;AAE5D,SAASC,cAAc,QAAQ,uBAAsB;AACrD,SAASC,kBAAkB,QAAQ,0BAAyB"}
|
{"version":3,"sources":["../../../src/modules/seo/index.ts"],"sourcesContent":["export { buildAutoFillMetaHook } from './autoFillMeta.js'\nexport type { AutoFillMapping } from './autoFillMeta.js'\nexport { buildIconsMetadata } from './buildIconsMetadata.js'\nexport { buildMetadata } from './buildMetadata.js'\nexport type { PageMetadata } from './buildMetadata.js'\nexport { buildOrganizationJsonLd } from './buildOrganizationJsonLd.js'\nexport { buildRobots } from './buildRobots.js'\nexport type { RobotsRules } from './buildRobots.js'\nexport { buildSitemapEntries } from './buildSitemapEntries.js'\nexport type { SitemapEntry } from './buildSitemapEntries.js'\nexport { composeTitle } from './composeTitle.js'\nexport type { TitleOrder } from './composeTitle.js'\nexport { createMetadataGenerator } from './createMetadataGenerator.js'\nexport { createPageMetadata } from './createPageMetadata.js'\nexport { buildHreflangAlternates } from './hreflang.js'\nexport { injectAutoFillMeta } from './injectAutoFillMeta.js'\nexport { injectSeoTabs } from './injectSeoTabs.js'\nexport { readSiteMetaConfig } from './readSiteMetaConfig.js'\nexport type { SiteMetaConfig } from './readSiteMetaConfig.js'\nexport { buildSeoPlugin } from './seoPluginConfig.js'\nexport { slugsAcrossLocales } from './slugsAcrossLocales.js'\nexport type { SeoMeta, SeoOption } from './types.js'\nexport { validateFaviconField } from './validateFavicon.js'\n"],"names":["buildAutoFillMetaHook","buildIconsMetadata","buildMetadata","buildOrganizationJsonLd","buildRobots","buildSitemapEntries","composeTitle","createMetadataGenerator","createPageMetadata","buildHreflangAlternates","injectAutoFillMeta","injectSeoTabs","readSiteMetaConfig","buildSeoPlugin","slugsAcrossLocales","validateFaviconField"],"mappings":"AAAA,SAASA,qBAAqB,QAAQ,oBAAmB;AAEzD,SAASC,kBAAkB,QAAQ,0BAAyB;AAC5D,SAASC,aAAa,QAAQ,qBAAoB;AAElD,SAASC,uBAAuB,QAAQ,+BAA8B;AACtE,SAASC,WAAW,QAAQ,mBAAkB;AAE9C,SAASC,mBAAmB,QAAQ,2BAA0B;AAE9D,SAASC,YAAY,QAAQ,oBAAmB;AAEhD,SAASC,uBAAuB,QAAQ,+BAA8B;AACtE,SAASC,kBAAkB,QAAQ,0BAAyB;AAC5D,SAASC,uBAAuB,QAAQ,gBAAe;AACvD,SAASC,kBAAkB,QAAQ,0BAAyB;AAC5D,SAASC,aAAa,QAAQ,qBAAoB;AAClD,SAASC,kBAAkB,QAAQ,0BAAyB;AAE5D,SAASC,cAAc,QAAQ,uBAAsB;AACrD,SAASC,kBAAkB,QAAQ,0BAAyB;AAE5D,SAASC,oBAAoB,QAAQ,uBAAsB"}
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
import type { FieldHook } from 'payload';
|
||||||
|
/**
|
||||||
|
* Field validation for the favicon upload: Google rejects favicons under 48×48,
|
||||||
|
* so warn the editor at save time if the uploaded icon is too small or not
|
||||||
|
* square. This ENFORCES the requirement instead of silently shipping a favicon
|
||||||
|
* Google won't display.
|
||||||
|
*
|
||||||
|
* Attach to the favicon field's validate (or as a beforeValidate hook on the
|
||||||
|
* Media relationship). Non-blocking by default — returns a warning string that
|
||||||
|
* Payload surfaces; make it throw if you want a hard block.
|
||||||
|
*
|
||||||
|
* Note: dimensions come from the related Media doc (Payload stores width/height
|
||||||
|
* for image uploads), so this checks the resolved upload, not the raw file.
|
||||||
|
*/
|
||||||
|
export declare const validateFaviconField: FieldHook;
|
||||||
Vendored
+44
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* Field validation for the favicon upload: Google rejects favicons under 48×48,
|
||||||
|
* so warn the editor at save time if the uploaded icon is too small or not
|
||||||
|
* square. This ENFORCES the requirement instead of silently shipping a favicon
|
||||||
|
* Google won't display.
|
||||||
|
*
|
||||||
|
* Attach to the favicon field's validate (or as a beforeValidate hook on the
|
||||||
|
* Media relationship). Non-blocking by default — returns a warning string that
|
||||||
|
* Payload surfaces; make it throw if you want a hard block.
|
||||||
|
*
|
||||||
|
* Note: dimensions come from the related Media doc (Payload stores width/height
|
||||||
|
* for image uploads), so this checks the resolved upload, not the raw file.
|
||||||
|
*/ export const validateFaviconField = async ({ req, value })=>{
|
||||||
|
if (!value) {
|
||||||
|
return value;
|
||||||
|
} // no favicon set → nothing to validate (optional field)
|
||||||
|
try {
|
||||||
|
const media = await req.payload.findByID({
|
||||||
|
id: typeof value === 'object' ? value.id : value,
|
||||||
|
collection: 'media',
|
||||||
|
depth: 0
|
||||||
|
});
|
||||||
|
const width = media.width;
|
||||||
|
const height = media.height;
|
||||||
|
const mimeType = media.mimeType;
|
||||||
|
// SVG scales infinitely — skip size checks.
|
||||||
|
if (mimeType === 'image/svg+xml') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof width === 'number' && typeof height === 'number') {
|
||||||
|
if (width < 48 || height < 48) {
|
||||||
|
req.payload.logger.warn(`[ipal] Favicon is ${width}×${height}px. Google requires ≥48×48 to ` + `display it in search results. Upload a larger square icon (96 or 192px).`);
|
||||||
|
}
|
||||||
|
if (width !== height) {
|
||||||
|
req.payload.logger.warn(`[ipal] Favicon is not square (${width}×${height}). Use a square icon ` + `so it isn't cropped in the browser tab or search results.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Media lookup failed — don't block the save over a validation warning.
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
//# sourceMappingURL=validateFavicon.js.map
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"sources":["../../../src/modules/seo/validateFavicon.ts"],"sourcesContent":["import type { FieldHook } from 'payload'\n\n/**\n * Field validation for the favicon upload: Google rejects favicons under 48×48,\n * so warn the editor at save time if the uploaded icon is too small or not\n * square. This ENFORCES the requirement instead of silently shipping a favicon\n * Google won't display.\n *\n * Attach to the favicon field's validate (or as a beforeValidate hook on the\n * Media relationship). Non-blocking by default — returns a warning string that\n * Payload surfaces; make it throw if you want a hard block.\n *\n * Note: dimensions come from the related Media doc (Payload stores width/height\n * for image uploads), so this checks the resolved upload, not the raw file.\n */\nexport const validateFaviconField: FieldHook = async ({ req, value }) => {\n if (!value) {return value} // no favicon set → nothing to validate (optional field)\n\n try {\n const media = await req.payload.findByID({\n id: typeof value === 'object' ? (value as { id: string }).id : value,\n collection: 'media',\n depth: 0,\n })\n\n const width = (media as { width?: number }).width\n const height = (media as { height?: number }).height\n const mimeType = (media as { mimeType?: string }).mimeType\n\n // SVG scales infinitely — skip size checks.\n if (mimeType === 'image/svg+xml') {return value}\n\n if (typeof width === 'number' && typeof height === 'number') {\n if (width < 48 || height < 48) {\n req.payload.logger.warn(\n `[ipal] Favicon is ${width}×${height}px. Google requires ≥48×48 to ` +\n `display it in search results. Upload a larger square icon (96 or 192px).`,\n )\n }\n if (width !== height) {\n req.payload.logger.warn(\n `[ipal] Favicon is not square (${width}×${height}). Use a square icon ` +\n `so it isn't cropped in the browser tab or search results.`,\n )\n }\n }\n } catch {\n // Media lookup failed — don't block the save over a validation warning.\n }\n\n return value\n}\n"],"names":["validateFaviconField","req","value","media","payload","findByID","id","collection","depth","width","height","mimeType","logger","warn"],"mappings":"AAEA;;;;;;;;;;;;CAYC,GACD,OAAO,MAAMA,uBAAkC,OAAO,EAAEC,GAAG,EAAEC,KAAK,EAAE;IAClE,IAAI,CAACA,OAAO;QAAC,OAAOA;IAAK,EAAE,wDAAwD;IAEnF,IAAI;QACF,MAAMC,QAAQ,MAAMF,IAAIG,OAAO,CAACC,QAAQ,CAAC;YACvCC,IAAI,OAAOJ,UAAU,WAAW,AAACA,MAAyBI,EAAE,GAAGJ;YAC/DK,YAAY;YACZC,OAAO;QACT;QAEA,MAAMC,QAAQ,AAACN,MAA6BM,KAAK;QACjD,MAAMC,SAAS,AAACP,MAA8BO,MAAM;QACpD,MAAMC,WAAW,AAACR,MAAgCQ,QAAQ;QAE1D,4CAA4C;QAC5C,IAAIA,aAAa,iBAAiB;YAAC,OAAOT;QAAK;QAE/C,IAAI,OAAOO,UAAU,YAAY,OAAOC,WAAW,UAAU;YAC3D,IAAID,QAAQ,MAAMC,SAAS,IAAI;gBAC7BT,IAAIG,OAAO,CAACQ,MAAM,CAACC,IAAI,CACrB,CAAC,kBAAkB,EAAEJ,MAAM,CAAC,EAAEC,OAAO,8BAA8B,CAAC,GAClE,CAAC,wEAAwE,CAAC;YAEhF;YACA,IAAID,UAAUC,QAAQ;gBACpBT,IAAIG,OAAO,CAACQ,MAAM,CAACC,IAAI,CACrB,CAAC,8BAA8B,EAAEJ,MAAM,CAAC,EAAEC,OAAO,qBAAqB,CAAC,GACrE,CAAC,yDAAyD,CAAC;YAEjE;QACF;IACF,EAAE,OAAM;IACN,wEAAwE;IAC1E;IAEA,OAAOR;AACT,EAAC"}
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* Emits <link rel="preconnect"> + <link rel="dns-prefetch"> for the media CDN
|
||||||
|
* domain (R2_PUBLIC_URL), so the browser opens the TLS/DNS connection to the
|
||||||
|
* media host early — before it hits the first <img>. Saves ~150–300ms on the
|
||||||
|
* first image load.
|
||||||
|
*
|
||||||
|
* Reads the domain from R2_PUBLIC_URL (the same env var buildR2Storage uses),
|
||||||
|
* so there's ONE source of truth — no per-project hardcoded domain. Renders
|
||||||
|
* nothing when R2_PUBLIC_URL isn't set (local disk / no CDN → nothing to
|
||||||
|
* preconnect).
|
||||||
|
*
|
||||||
|
* Server Component — drop it in the <head> of your locale layout:
|
||||||
|
*
|
||||||
|
* import { MediaPreconnect } from '@intecion/ipal-kit/rsc'
|
||||||
|
* // in <head> (or top of <body> — Next hoists link tags):
|
||||||
|
* <MediaPreconnect />
|
||||||
|
*/
|
||||||
|
export declare function MediaPreconnect(): import("react/jsx-runtime").JSX.Element | null;
|
||||||
|
export default MediaPreconnect;
|
||||||
+47
@@ -0,0 +1,47 @@
|
|||||||
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
||||||
|
/**
|
||||||
|
* Emits <link rel="preconnect"> + <link rel="dns-prefetch"> for the media CDN
|
||||||
|
* domain (R2_PUBLIC_URL), so the browser opens the TLS/DNS connection to the
|
||||||
|
* media host early — before it hits the first <img>. Saves ~150–300ms on the
|
||||||
|
* first image load.
|
||||||
|
*
|
||||||
|
* Reads the domain from R2_PUBLIC_URL (the same env var buildR2Storage uses),
|
||||||
|
* so there's ONE source of truth — no per-project hardcoded domain. Renders
|
||||||
|
* nothing when R2_PUBLIC_URL isn't set (local disk / no CDN → nothing to
|
||||||
|
* preconnect).
|
||||||
|
*
|
||||||
|
* Server Component — drop it in the <head> of your locale layout:
|
||||||
|
*
|
||||||
|
* import { MediaPreconnect } from '@intecion/ipal-kit/rsc'
|
||||||
|
* // in <head> (or top of <body> — Next hoists link tags):
|
||||||
|
* <MediaPreconnect />
|
||||||
|
*/ export function MediaPreconnect() {
|
||||||
|
const publicUrl = process.env.R2_PUBLIC_URL?.replace(/\/$/, '');
|
||||||
|
if (!publicUrl) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// Origin only (scheme + host) — preconnect targets an origin, not a path.
|
||||||
|
let origin;
|
||||||
|
try {
|
||||||
|
origin = new URL(publicUrl).origin;
|
||||||
|
} catch {
|
||||||
|
return null // malformed URL → skip rather than emit a broken tag
|
||||||
|
;
|
||||||
|
}
|
||||||
|
return /*#__PURE__*/ _jsxs(_Fragment, {
|
||||||
|
children: [
|
||||||
|
/*#__PURE__*/ _jsx("link", {
|
||||||
|
crossOrigin: "anonymous",
|
||||||
|
href: origin,
|
||||||
|
rel: "preconnect"
|
||||||
|
}),
|
||||||
|
/*#__PURE__*/ _jsx("link", {
|
||||||
|
href: origin,
|
||||||
|
rel: "dns-prefetch"
|
||||||
|
})
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
export default MediaPreconnect;
|
||||||
|
|
||||||
|
//# sourceMappingURL=MediaPreconnect.js.map
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"sources":["../../../src/modules/storage/MediaPreconnect.tsx"],"sourcesContent":["/**\n * Emits <link rel=\"preconnect\"> + <link rel=\"dns-prefetch\"> for the media CDN\n * domain (R2_PUBLIC_URL), so the browser opens the TLS/DNS connection to the\n * media host early — before it hits the first <img>. Saves ~150–300ms on the\n * first image load.\n *\n * Reads the domain from R2_PUBLIC_URL (the same env var buildR2Storage uses),\n * so there's ONE source of truth — no per-project hardcoded domain. Renders\n * nothing when R2_PUBLIC_URL isn't set (local disk / no CDN → nothing to\n * preconnect).\n *\n * Server Component — drop it in the <head> of your locale layout:\n *\n * import { MediaPreconnect } from '@intecion/ipal-kit/rsc'\n * // in <head> (or top of <body> — Next hoists link tags):\n * <MediaPreconnect />\n */\nexport function MediaPreconnect() {\n const publicUrl = process.env.R2_PUBLIC_URL?.replace(/\\/$/, '')\n if (!publicUrl) {return null}\n\n // Origin only (scheme + host) — preconnect targets an origin, not a path.\n let origin: string\n try {\n origin = new URL(publicUrl).origin\n } catch {\n return null // malformed URL → skip rather than emit a broken tag\n }\n\n return (\n <>\n <link crossOrigin=\"anonymous\" href={origin} rel=\"preconnect\" />\n <link href={origin} rel=\"dns-prefetch\" />\n </>\n )\n}\n\nexport default MediaPreconnect\n"],"names":["MediaPreconnect","publicUrl","process","env","R2_PUBLIC_URL","replace","origin","URL","link","crossOrigin","href","rel"],"mappings":";AAAA;;;;;;;;;;;;;;;;CAgBC,GACD,OAAO,SAASA;IACd,MAAMC,YAAYC,QAAQC,GAAG,CAACC,aAAa,EAAEC,QAAQ,OAAO;IAC5D,IAAI,CAACJ,WAAW;QAAC,OAAO;IAAI;IAE5B,0EAA0E;IAC1E,IAAIK;IACJ,IAAI;QACFA,SAAS,IAAIC,IAAIN,WAAWK,MAAM;IACpC,EAAE,OAAM;QACN,OAAO,KAAK,qDAAqD;;IACnE;IAEA,qBACE;;0BACE,KAACE;gBAAKC,aAAY;gBAAYC,MAAMJ;gBAAQK,KAAI;;0BAChD,KAACH;gBAAKE,MAAMJ;gBAAQK,KAAI;;;;AAG9B;AAEA,eAAeX,gBAAe"}
|
||||||
+19
-20
@@ -31,32 +31,31 @@ import { s3Storage } from '@payloadcms/storage-s3';
|
|||||||
return config;
|
return config;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
// s3Storage wants Record<string, true> (the literal true, per collection),
|
// Public URL for served media. R2 is private by default; its S3 endpoint only
|
||||||
// not Record<string, boolean>. Object.fromEntries widens true → boolean, so
|
// accepts uploads and won't serve files (403). With a custom domain
|
||||||
// build the map with an explicitly-typed accumulator to keep the literal.
|
|
||||||
const collectionsConfig = {};
|
|
||||||
for (const slug of collections){
|
|
||||||
collectionsConfig[slug] = true;
|
|
||||||
}
|
|
||||||
// Public URL for served media. R2 is private by default; with a custom domain
|
|
||||||
// (media.klient.pl → bucket) set R2_PUBLIC_URL so Payload generates URLs
|
// (media.klient.pl → bucket) set R2_PUBLIC_URL so Payload generates URLs
|
||||||
// pointing there instead of the private S3 endpoint (which 403s on the front).
|
// pointing there. Without it, uploads work but images don't display publicly.
|
||||||
// Without it, uploads work but images don't display publicly. See docs/storage.md.
|
// See docs/storage.md.
|
||||||
const publicUrl = process.env.R2_PUBLIC_URL?.replace(/\/$/, '') // strip trailing slash
|
const publicUrl = process.env.R2_PUBLIC_URL?.replace(/\/$/, '') // strip trailing slash
|
||||||
;
|
;
|
||||||
return s3Storage({
|
// generateFileURL is a PER-COLLECTION option in @payloadcms/storage-s3 (not a
|
||||||
bucket,
|
// top-level one) — R2 needs it to point served URLs at the custom domain
|
||||||
collections: collectionsConfig,
|
// instead of the private S3 endpoint. Each collection gets either `true`
|
||||||
...publicUrl ? {
|
// (plain offload) or an object carrying generateFileURL when a public URL is set.
|
||||||
// generateFileURL overrides the stored/returned URL to the CDN domain.
|
const generateFileURL = publicUrl ? ({ filename, prefix })=>[
|
||||||
// Params come from Payload's storage plugin; type them explicitly since
|
|
||||||
// the callback shape isn't inferred here (would be implicit any).
|
|
||||||
generateFileURL: ({ filename, prefix })=>[
|
|
||||||
publicUrl,
|
publicUrl,
|
||||||
prefix,
|
prefix,
|
||||||
filename
|
filename
|
||||||
].filter(Boolean).join('/')
|
].filter(Boolean).join('/') : undefined;
|
||||||
} : {},
|
const collectionsConfig = {};
|
||||||
|
for (const slug of collections){
|
||||||
|
collectionsConfig[slug] = generateFileURL ? {
|
||||||
|
generateFileURL
|
||||||
|
} : true;
|
||||||
|
}
|
||||||
|
return s3Storage({
|
||||||
|
bucket,
|
||||||
|
collections: collectionsConfig,
|
||||||
config: {
|
config: {
|
||||||
credentials: {
|
credentials: {
|
||||||
accessKeyId,
|
accessKeyId,
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
{"version":3,"sources":["../../../src/modules/storage/buildR2Storage.ts"],"sourcesContent":["import type { Plugin } from 'payload'\n\nimport { s3Storage } from '@payloadcms/storage-s3'\n\n/**\n * Cloudflare R2 media storage — configured from environment variables (agency\n * infrastructure, not per-project panel data). R2 is S3-compatible, so we use\n * @payloadcms/storage-s3 pointed at the R2 endpoint.\n *\n * Storage is infrastructure (like the database or PAYLOAD_SECRET): it binds at\n * boot, and its credentials are agency-owned — so it lives in .env, not the\n * panel. See docs/storage.md for the required variables.\n *\n * Returns the storage plugin when all R2 vars are present; otherwise returns a\n * no-op passthrough so the project falls back to Payload's default local disk\n * storage (useful in dev without R2). This mirrors how mailAdapter degrades\n * gracefully when a transport isn't configured.\n *\n * @param collections - slugs of upload collections to offload to R2 (e.g. ['media'])\n */\nexport const buildR2Storage = (collections: string[] = ['media']): Plugin => {\n const bucket = process.env.R2_BUCKET\n const endpoint = process.env.R2_ENDPOINT\n const accessKeyId = process.env.R2_ACCESS_KEY_ID\n const secretAccessKey = process.env.R2_SECRET_ACCESS_KEY\n\n // Any missing → skip R2, fall back to local disk. Warn so it's not silent.\n if (!bucket || !endpoint || !accessKeyId || !secretAccessKey) {\n return (config) => {\n // Only warn when SOME vars are set (partial config = likely a mistake).\n if (bucket || endpoint || accessKeyId || secretAccessKey) {\n console.warn(\n '[ipal] R2 storage: incomplete env (need R2_BUCKET, R2_ENDPOINT, ' +\n 'R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY). Falling back to local disk.',\n )\n }\n return config\n }\n }\n\n // s3Storage wants Record<string, true> (the literal true, per collection),\n // not Record<string, boolean>. Object.fromEntries widens true → boolean, so\n // build the map with an explicitly-typed accumulator to keep the literal.\n const collectionsConfig: Record<string, true> = {}\n for (const slug of collections) {\n collectionsConfig[slug] = true\n }\n\n // Public URL for served media. R2 is private by default; with a custom domain\n // (media.klient.pl → bucket) set R2_PUBLIC_URL so Payload generates URLs\n // pointing there instead of the private S3 endpoint (which 403s on the front).\n // Without it, uploads work but images don't display publicly. See docs/storage.md.\n const publicUrl = process.env.R2_PUBLIC_URL?.replace(/\\/$/, '') // strip trailing slash\n\n return s3Storage({\n bucket,\n collections: collectionsConfig,\n ...(publicUrl\n ? {\n // generateFileURL overrides the stored/returned URL to the CDN domain.\n // Params come from Payload's storage plugin; type them explicitly since\n // the callback shape isn't inferred here (would be implicit any).\n generateFileURL: ({ filename, prefix }: { filename: string; prefix?: string }) =>\n [publicUrl, prefix, filename].filter(Boolean).join('/'),\n }\n : {}),\n config: {\n credentials: { accessKeyId, secretAccessKey },\n endpoint,\n region: 'auto', // R2 uses 'auto'\n // R2 requires path-style addressing for S3 compatibility.\n forcePathStyle: true,\n },\n })\n}\n"],"names":["s3Storage","buildR2Storage","collections","bucket","process","env","R2_BUCKET","endpoint","R2_ENDPOINT","accessKeyId","R2_ACCESS_KEY_ID","secretAccessKey","R2_SECRET_ACCESS_KEY","config","console","warn","collectionsConfig","slug","publicUrl","R2_PUBLIC_URL","replace","generateFileURL","filename","prefix","filter","Boolean","join","credentials","region","forcePathStyle"],"mappings":"AAEA,SAASA,SAAS,QAAQ,yBAAwB;AAElD;;;;;;;;;;;;;;;CAeC,GACD,OAAO,MAAMC,iBAAiB,CAACC,cAAwB;IAAC;CAAQ;IAC9D,MAAMC,SAASC,QAAQC,GAAG,CAACC,SAAS;IACpC,MAAMC,WAAWH,QAAQC,GAAG,CAACG,WAAW;IACxC,MAAMC,cAAcL,QAAQC,GAAG,CAACK,gBAAgB;IAChD,MAAMC,kBAAkBP,QAAQC,GAAG,CAACO,oBAAoB;IAExD,2EAA2E;IAC3E,IAAI,CAACT,UAAU,CAACI,YAAY,CAACE,eAAe,CAACE,iBAAiB;QAC5D,OAAO,CAACE;YACN,wEAAwE;YACxE,IAAIV,UAAUI,YAAYE,eAAeE,iBAAiB;gBACxDG,QAAQC,IAAI,CACV,qEACE;YAEN;YACA,OAAOF;QACT;IACF;IAEA,2EAA2E;IAC3E,4EAA4E;IAC5E,0EAA0E;IAC1E,MAAMG,oBAA0C,CAAC;IACjD,KAAK,MAAMC,QAAQf,YAAa;QAC9Bc,iBAAiB,CAACC,KAAK,GAAG;IAC5B;IAEA,8EAA8E;IAC9E,yEAAyE;IACzE,+EAA+E;IAC/E,mFAAmF;IACnF,MAAMC,YAAYd,QAAQC,GAAG,CAACc,aAAa,EAAEC,QAAQ,OAAO,IAAI,uBAAuB;;IAEvF,OAAOpB,UAAU;QACfG;QACAD,aAAac;QACb,GAAIE,YACA;YACE,uEAAuE;YACvE,wEAAwE;YACxE,kEAAkE;YAClEG,iBAAiB,CAAC,EAAEC,QAAQ,EAAEC,MAAM,EAAyC,GAC3E;oBAACL;oBAAWK;oBAAQD;iBAAS,CAACE,MAAM,CAACC,SAASC,IAAI,CAAC;QACvD,IACA,CAAC,CAAC;QACNb,QAAQ;YACNc,aAAa;gBAAElB;gBAAaE;YAAgB;YAC5CJ;YACAqB,QAAQ;YACR,0DAA0D;YAC1DC,gBAAgB;QAClB;IACF;AACF,EAAC"}
|
{"version":3,"sources":["../../../src/modules/storage/buildR2Storage.ts"],"sourcesContent":["import type { Plugin } from 'payload'\n\nimport { s3Storage } from '@payloadcms/storage-s3'\n\n/**\n * Cloudflare R2 media storage — configured from environment variables (agency\n * infrastructure, not per-project panel data). R2 is S3-compatible, so we use\n * @payloadcms/storage-s3 pointed at the R2 endpoint.\n *\n * Storage is infrastructure (like the database or PAYLOAD_SECRET): it binds at\n * boot, and its credentials are agency-owned — so it lives in .env, not the\n * panel. See docs/storage.md for the required variables.\n *\n * Returns the storage plugin when all R2 vars are present; otherwise returns a\n * no-op passthrough so the project falls back to Payload's default local disk\n * storage (useful in dev without R2). This mirrors how mailAdapter degrades\n * gracefully when a transport isn't configured.\n *\n * @param collections - slugs of upload collections to offload to R2 (e.g. ['media'])\n */\nexport const buildR2Storage = (collections: string[] = ['media']): Plugin => {\n const bucket = process.env.R2_BUCKET\n const endpoint = process.env.R2_ENDPOINT\n const accessKeyId = process.env.R2_ACCESS_KEY_ID\n const secretAccessKey = process.env.R2_SECRET_ACCESS_KEY\n\n // Any missing → skip R2, fall back to local disk. Warn so it's not silent.\n if (!bucket || !endpoint || !accessKeyId || !secretAccessKey) {\n return (config) => {\n // Only warn when SOME vars are set (partial config = likely a mistake).\n if (bucket || endpoint || accessKeyId || secretAccessKey) {\n console.warn(\n '[ipal] R2 storage: incomplete env (need R2_BUCKET, R2_ENDPOINT, ' +\n 'R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY). Falling back to local disk.',\n )\n }\n return config\n }\n }\n\n // Public URL for served media. R2 is private by default; its S3 endpoint only\n // accepts uploads and won't serve files (403). With a custom domain\n // (media.klient.pl → bucket) set R2_PUBLIC_URL so Payload generates URLs\n // pointing there. Without it, uploads work but images don't display publicly.\n // See docs/storage.md.\n const publicUrl = process.env.R2_PUBLIC_URL?.replace(/\\/$/, '') // strip trailing slash\n\n // generateFileURL is a PER-COLLECTION option in @payloadcms/storage-s3 (not a\n // top-level one) — R2 needs it to point served URLs at the custom domain\n // instead of the private S3 endpoint. Each collection gets either `true`\n // (plain offload) or an object carrying generateFileURL when a public URL is set.\n const generateFileURL = publicUrl\n ? ({ filename, prefix }: { filename: string; prefix?: string }) =>\n [publicUrl, prefix, filename].filter(Boolean).join('/')\n : undefined\n\n const collectionsConfig: Record<string, { generateFileURL: typeof generateFileURL } | true> = {}\n for (const slug of collections) {\n collectionsConfig[slug] = generateFileURL ? { generateFileURL } : true\n }\n\n return s3Storage({\n bucket,\n collections: collectionsConfig,\n config: {\n credentials: { accessKeyId, secretAccessKey },\n endpoint,\n region: 'auto', // R2 uses 'auto'\n // R2 requires path-style addressing for S3 compatibility.\n forcePathStyle: true,\n },\n })\n}\n"],"names":["s3Storage","buildR2Storage","collections","bucket","process","env","R2_BUCKET","endpoint","R2_ENDPOINT","accessKeyId","R2_ACCESS_KEY_ID","secretAccessKey","R2_SECRET_ACCESS_KEY","config","console","warn","publicUrl","R2_PUBLIC_URL","replace","generateFileURL","filename","prefix","filter","Boolean","join","undefined","collectionsConfig","slug","credentials","region","forcePathStyle"],"mappings":"AAEA,SAASA,SAAS,QAAQ,yBAAwB;AAElD;;;;;;;;;;;;;;;CAeC,GACD,OAAO,MAAMC,iBAAiB,CAACC,cAAwB;IAAC;CAAQ;IAC9D,MAAMC,SAASC,QAAQC,GAAG,CAACC,SAAS;IACpC,MAAMC,WAAWH,QAAQC,GAAG,CAACG,WAAW;IACxC,MAAMC,cAAcL,QAAQC,GAAG,CAACK,gBAAgB;IAChD,MAAMC,kBAAkBP,QAAQC,GAAG,CAACO,oBAAoB;IAExD,2EAA2E;IAC3E,IAAI,CAACT,UAAU,CAACI,YAAY,CAACE,eAAe,CAACE,iBAAiB;QAC5D,OAAO,CAACE;YACN,wEAAwE;YACxE,IAAIV,UAAUI,YAAYE,eAAeE,iBAAiB;gBACxDG,QAAQC,IAAI,CACV,qEACE;YAEN;YACA,OAAOF;QACT;IACF;IAEA,8EAA8E;IAC9E,oEAAoE;IACpE,yEAAyE;IACzE,8EAA8E;IAC9E,uBAAuB;IACvB,MAAMG,YAAYZ,QAAQC,GAAG,CAACY,aAAa,EAAEC,QAAQ,OAAO,IAAI,uBAAuB;;IAEvF,8EAA8E;IAC9E,yEAAyE;IACzE,yEAAyE;IACzE,kFAAkF;IAClF,MAAMC,kBAAkBH,YACpB,CAAC,EAAEI,QAAQ,EAAEC,MAAM,EAAyC,GAC1D;YAACL;YAAWK;YAAQD;SAAS,CAACE,MAAM,CAACC,SAASC,IAAI,CAAC,OACrDC;IAEJ,MAAMC,oBAAwF,CAAC;IAC/F,KAAK,MAAMC,QAAQzB,YAAa;QAC9BwB,iBAAiB,CAACC,KAAK,GAAGR,kBAAkB;YAAEA;QAAgB,IAAI;IACpE;IAEA,OAAOnB,UAAU;QACfG;QACAD,aAAawB;QACbb,QAAQ;YACNe,aAAa;gBAAEnB;gBAAaE;YAAgB;YAC5CJ;YACAsB,QAAQ;YACR,0DAA0D;YAC1DC,gBAAgB;QAClB;IACF;AACF,EAAC"}
|
||||||
+79
@@ -248,3 +248,82 @@ const entries = await buildSitemapEntries({
|
|||||||
|
|
||||||
Przy dziesiątkach tysięcy URL-i Next ma `generateSitemaps` do dzielenia na
|
Przy dziesiątkach tysięcy URL-i Next ma `generateSitemaps` do dzielenia na
|
||||||
części — dodasz, gdyby realnie było ich tyle. Jedna sitemapa wystarcza do ~50k.
|
części — dodasz, gdyby realnie było ich tyle. Jedna sitemapa wystarcza do ~50k.
|
||||||
|
|
||||||
|
## Favicon w Google + Organization (branding w wyszukiwarce)
|
||||||
|
|
||||||
|
Favicon i structured data wpływają na to, jak strona wygląda w wynikach Google.
|
||||||
|
Plugin generuje jedno i drugie z panelu — projekt tylko wpina w root layout.
|
||||||
|
|
||||||
|
### Favicon — dlaczego się nie pokazywał
|
||||||
|
|
||||||
|
Google ma twarde wymogi: `<link rel="icon">` w `<head>`, kwadratowy, **≥48×48px**,
|
||||||
|
stały URL. Gdy projekt renderował favicon „po swojemu", często był za mały, źle
|
||||||
|
otagowany albo nieobecny w head → Google go nie pokazywał. Plugin robi to teraz
|
||||||
|
poprawnie.
|
||||||
|
|
||||||
|
### Wpięcie favicon (root layout)
|
||||||
|
|
||||||
|
Favicon jest GLOBALNY (ten sam wszędzie) — wpina się RAZ w root layout, nie per strona:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app/(frontend)/[locale]/layout.tsx
|
||||||
|
import type { Metadata } from 'next'
|
||||||
|
import { buildIconsMetadata } from '@intecion/ipal-kit'
|
||||||
|
import { getSettings } from '@/lib/payload'
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }): Promise<Metadata> {
|
||||||
|
const { locale } = await params
|
||||||
|
const settings = await getSettings(locale)
|
||||||
|
return buildIconsMetadata(settings.favicon) // z pola favicon (panel)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`buildIconsMetadata` generuje poprawne `icons` (favicon + apple-touch) z pola
|
||||||
|
favicon. SVG → skaluje się; PNG → powinien być ≥48×48 (walidacja ostrzega, patrz niżej).
|
||||||
|
|
||||||
|
### Wymuszenie rozmiaru (walidacja)
|
||||||
|
|
||||||
|
Pole favicon w SiteSettings ma walidację `validateFaviconField` — ostrzega
|
||||||
|
redaktora przy zapisie, jeśli favicon jest <48×48 albo nie kwadratowy. Redaktor
|
||||||
|
widzi ostrzeżenie, zamiast po cichu wgrać favicon, którego Google nie pokaże.
|
||||||
|
|
||||||
|
### Organization JSON-LD (branding)
|
||||||
|
|
||||||
|
Pomaga Google powiązać stronę z marką (nazwa, logo) — lepsze wyświetlanie w
|
||||||
|
wynikach, logo w knowledge panel.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// root layout — RAZ (Organization jest globalny)
|
||||||
|
import { buildOrganizationJsonLd } from '@intecion/ipal-kit'
|
||||||
|
|
||||||
|
const jsonLd = buildOrganizationJsonLd({
|
||||||
|
name: settings.siteName,
|
||||||
|
url: process.env.NEXT_PUBLIC_SERVER_URL!,
|
||||||
|
logo: settings.logo,
|
||||||
|
sameAs: settings.socialLinks, // opcjonalne: profile społecznościowe
|
||||||
|
})
|
||||||
|
|
||||||
|
// w JSX layoutu:
|
||||||
|
<script
|
||||||
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
Dane z panelu (siteName, logo) — nic na sztywno.
|
||||||
|
|
||||||
|
### Po wdrożeniu — cierpliwość z Google
|
||||||
|
|
||||||
|
Google **cache'uje favicon osobno i wolno** (dni, czasem tygodnie). Po poprawnym
|
||||||
|
wpięciu favicon nie pojawi się natychmiast — Googlebot musi ponownie odwiedzić
|
||||||
|
stronę główną. Przyspieszenie: Search Console → prośba o ponowne indeksowanie
|
||||||
|
strony głównej. Sprawdź też, czy `/` nie blokuje Googlebota (robots) i czy
|
||||||
|
favicon URL jest publiczny (nie za auth).
|
||||||
|
|
||||||
|
### Weryfikacja
|
||||||
|
|
||||||
|
1. Otwórz stronę → DevTools → Elements → `<head>` → sprawdź `<link rel="icon">`
|
||||||
|
z poprawnym URL.
|
||||||
|
2. Otwórz sam URL favicon w przeglądarce — obraz się pokazuje, ≥48×48.
|
||||||
|
3. Rich Results Test (Google) — wklej URL strony, sprawdź Organization.
|
||||||
|
4. Search Console → poproś o ponowne indeksowanie strony głównej.
|
||||||
@@ -184,3 +184,28 @@ czysta nazwa trafia i do bazy, i do bucketa). Dlaczego to ważne:
|
|||||||
|
|
||||||
Sama funkcja `normalizeFilename(name)` też jest wyeksportowana, gdybyś potrzebował
|
Sama funkcja `normalizeFilename(name)` też jest wyeksportowana, gdybyś potrzebował
|
||||||
jej poza hookiem.
|
jej poza hookiem.
|
||||||
|
|
||||||
|
## Preconnect do domeny mediów (wydajność)
|
||||||
|
|
||||||
|
Komponent `MediaPreconnect` generuje `<link rel="preconnect">` + `dns-prefetch`
|
||||||
|
dla domeny mediów (R2_PUBLIC_URL) — przeglądarka nawiązuje połączenie TLS/DNS
|
||||||
|
z CDN zawczasu, zanim napotka pierwszy `<img>`. Zysk ~150–300 ms na pierwszym
|
||||||
|
obrazie.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// layout.tsx — w <head> (albo górze <body>, Next hoistuje link tagi)
|
||||||
|
import { MediaPreconnect } from '@intecion/ipal-kit/rsc'
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<MediaPreconnect />
|
||||||
|
</head>
|
||||||
|
```
|
||||||
|
|
||||||
|
Czyta domenę z **R2_PUBLIC_URL** (to samo źródło co buildR2Storage) — zero
|
||||||
|
hardkodu, jedno źródło prawdy. Gdy R2_PUBLIC_URL nie ustawione (lokalny dysk,
|
||||||
|
brak CDN) → nie renderuje nic. Zmiana domeny mediów = zmiana jednej zmiennej
|
||||||
|
env, komponent podąża automatycznie.
|
||||||
|
|
||||||
|
> NIE hardkoduj `<link rel="preconnect" href="https://media.klient.pl">` ręcznie
|
||||||
|
> w layoutcie — to zaszywa domenę klienta w kodzie. Użyj MediaPreconnect, który
|
||||||
|
> bierze ją z env.
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@intecion/ipal-kit",
|
"name": "@intecion/ipal-kit",
|
||||||
"version": "1.0.21",
|
"version": "1.1.1",
|
||||||
"description": "Intecion Payload Advanced Library — a Payload CMS 3 plugin: i18n, SEO, forms, consent, analytics, blog/archives.",
|
"description": "Intecion Payload Advanced Library — a Payload CMS 3 plugin: i18n, SEO, forms, consent, analytics, blog/archives.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -12,3 +12,4 @@ export type {
|
|||||||
EnhanceProps,
|
EnhanceProps,
|
||||||
RenderBlocksProps,
|
RenderBlocksProps,
|
||||||
} from '../modules/blocks/index.js'
|
} from '../modules/blocks/index.js'
|
||||||
|
export { MediaPreconnect } from '../modules/storage/MediaPreconnect.js'
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import type { Field } from 'payload'
|
import type { Field } from 'payload'
|
||||||
|
|
||||||
|
import { validateFaviconField } from '../../../modules/seo/index.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* General site identity fields.
|
* General site identity fields.
|
||||||
* Consumed by SEO/OG (siteName, defaultShareImage) and frontend (logo).
|
* Consumed by SEO/OG (siteName, defaultShareImage) and frontend (logo, favicon).
|
||||||
*/
|
*/
|
||||||
export const generalFields: Field[] = [
|
export const generalFields: Field[] = [
|
||||||
{
|
{
|
||||||
@@ -45,7 +47,8 @@ export const generalFields: Field[] = [
|
|||||||
name: 'logo',
|
name: 'logo',
|
||||||
type: 'upload',
|
type: 'upload',
|
||||||
admin: {
|
admin: {
|
||||||
description: 'Primary site logo.',
|
description:
|
||||||
|
'Primary site logo. Also used for Organization structured data (buildOrganizationJsonLd).',
|
||||||
},
|
},
|
||||||
relationTo: 'media',
|
relationTo: 'media',
|
||||||
},
|
},
|
||||||
@@ -61,7 +64,13 @@ export const generalFields: Field[] = [
|
|||||||
name: 'favicon',
|
name: 'favicon',
|
||||||
type: 'upload',
|
type: 'upload',
|
||||||
admin: {
|
admin: {
|
||||||
description: 'Square source icon (PNG or SVG) for the browser tab. Rendered by the frontend.',
|
description:
|
||||||
|
'Square icon, PNG or SVG, min. 48×48px (Google requires this to show it in search). Rendered via buildIconsMetadata.',
|
||||||
|
},
|
||||||
|
hooks: {
|
||||||
|
// Warns the editor at save time if the favicon is too small (<48×48) or
|
||||||
|
// not square — Google won't display such favicons in search results.
|
||||||
|
beforeValidate: [validateFaviconField],
|
||||||
},
|
},
|
||||||
relationTo: 'media',
|
relationTo: 'media',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -99,6 +99,11 @@ export {
|
|||||||
} from './modules/payload/index.js'
|
} from './modules/payload/index.js'
|
||||||
export { buildSecurityHeaders } from './modules/security/index.js'
|
export { buildSecurityHeaders } from './modules/security/index.js'
|
||||||
export type { BuildSecurityHeadersArgs, SecurityHeader } from './modules/security/index.js'
|
export type { BuildSecurityHeadersArgs, SecurityHeader } from './modules/security/index.js'
|
||||||
|
export {
|
||||||
|
buildIconsMetadata,
|
||||||
|
buildOrganizationJsonLd,
|
||||||
|
validateFaviconField,
|
||||||
|
} from './modules/seo/index.js'
|
||||||
export type { PageMetadata, SeoMeta, SeoOption } from './modules/seo/index.js'
|
export type { PageMetadata, SeoMeta, SeoOption } from './modules/seo/index.js'
|
||||||
export { buildHreflangAlternates, buildMetadata, composeTitle } from './modules/seo/index.js'
|
export { buildHreflangAlternates, buildMetadata, composeTitle } from './modules/seo/index.js'
|
||||||
export type { AutoFillMapping, RobotsRules, SitemapEntry } from './modules/seo/index.js'
|
export type { AutoFillMapping, RobotsRules, SitemapEntry } from './modules/seo/index.js'
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
|
||||||
|
type MediaLike =
|
||||||
|
| { height?: null | number; mimeType?: null | string; url?: null | string; width?: null | number }
|
||||||
|
| null
|
||||||
|
| undefined
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds Next.js `icons` metadata (favicon / apple-touch-icon) from the panel's
|
||||||
|
* favicon upload, so the browser tab AND Google get a proper <link rel="icon">.
|
||||||
|
*
|
||||||
|
* Why the plugin must do this (not the project): favicon-in-Google has strict
|
||||||
|
* rules — a real <link rel="icon"> in <head>, square, ≥48×48, at a stable URL.
|
||||||
|
* Leaving it to each project meant inconsistent hand-rolled tags and no favicon
|
||||||
|
* in search results. This generates the tags correctly, every time, from the
|
||||||
|
* panel field.
|
||||||
|
*
|
||||||
|
* Favicon is GLOBAL (same across pages), so call this once in the ROOT layout's
|
||||||
|
* generateMetadata — not per page:
|
||||||
|
*
|
||||||
|
* import { buildIconsMetadata } from '@intecion/ipal-kit'
|
||||||
|
* export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
* const settings = await getSettings(locale)
|
||||||
|
* return buildIconsMetadata(settings.favicon)
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* Google notes: it caches favicons separately and slowly (days/weeks), and only
|
||||||
|
* shows them for icons it deems valid. Warn on too-small icons at upload time
|
||||||
|
* (see the media validation hook) so editors don't ship a <48px favicon Google
|
||||||
|
* will reject.
|
||||||
|
*/
|
||||||
|
export function buildIconsMetadata(favicon: MediaLike): Metadata {
|
||||||
|
const url = favicon?.url
|
||||||
|
if (!url) {return {}}
|
||||||
|
|
||||||
|
const isSvg = favicon?.mimeType === 'image/svg+xml' || url.endsWith('.svg')
|
||||||
|
|
||||||
|
return {
|
||||||
|
icons: {
|
||||||
|
// Main favicon. SVG scales; PNG should be ≥48×48 (ideally 96 or 192).
|
||||||
|
icon: isSvg ? [{ type: 'image/svg+xml', url }] : [{ sizes: 'any', url }],
|
||||||
|
// Apple touch icon (home-screen bookmark on iOS). Reuses the same asset.
|
||||||
|
apple: [{ url }],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
type MediaLike = { url?: null | string } | null | undefined
|
||||||
|
|
||||||
|
type OrganizationJsonLdArgs = {
|
||||||
|
/** Logo media (from panel). Google uses this for brand knowledge panels. */
|
||||||
|
logo?: MediaLike
|
||||||
|
/** Organization / site name. */
|
||||||
|
name: string
|
||||||
|
/** Optional social / official profile URLs (sameAs). */
|
||||||
|
sameAs?: string[]
|
||||||
|
/** Absolute site URL (https://…). */
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds Organization JSON-LD (schema.org) — helps Google associate the site
|
||||||
|
* with a brand: name, logo, official links. Improves how the site appears in
|
||||||
|
* search (brand recognition, logo in knowledge panels) and is a signal used
|
||||||
|
* alongside favicon for identity.
|
||||||
|
*
|
||||||
|
* Returns a plain object; the project renders it as a <script type="application/
|
||||||
|
* ld+json"> in the root layout:
|
||||||
|
*
|
||||||
|
* import { buildOrganizationJsonLd } from '@intecion/ipal-kit'
|
||||||
|
* const jsonLd = buildOrganizationJsonLd({
|
||||||
|
* name: settings.siteName, url: baseUrl, logo: settings.logo,
|
||||||
|
* })
|
||||||
|
* <script type="application/ld+json"
|
||||||
|
* dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
|
||||||
|
*
|
||||||
|
* Data comes from the panel (siteName, logo) — nothing hardcoded. Emit once in
|
||||||
|
* the root layout (Organization is site-wide, not per page).
|
||||||
|
*/
|
||||||
|
export function buildOrganizationJsonLd({ name, logo, sameAs, url }: OrganizationJsonLdArgs) {
|
||||||
|
const logoUrl = logo?.url
|
||||||
|
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'Organization',
|
||||||
|
url,
|
||||||
|
...(logoUrl ? { logo: logoUrl.startsWith('http') ? logoUrl : `${url}${logoUrl}` } : {}),
|
||||||
|
...(sameAs && sameAs.length > 0 ? { sameAs } : {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
export { buildAutoFillMetaHook } from './autoFillMeta.js'
|
export { buildAutoFillMetaHook } from './autoFillMeta.js'
|
||||||
export type { AutoFillMapping } from './autoFillMeta.js'
|
export type { AutoFillMapping } from './autoFillMeta.js'
|
||||||
|
export { buildIconsMetadata } from './buildIconsMetadata.js'
|
||||||
export { buildMetadata } from './buildMetadata.js'
|
export { buildMetadata } from './buildMetadata.js'
|
||||||
export type { PageMetadata } from './buildMetadata.js'
|
export type { PageMetadata } from './buildMetadata.js'
|
||||||
|
export { buildOrganizationJsonLd } from './buildOrganizationJsonLd.js'
|
||||||
export { buildRobots } from './buildRobots.js'
|
export { buildRobots } from './buildRobots.js'
|
||||||
export type { RobotsRules } from './buildRobots.js'
|
export type { RobotsRules } from './buildRobots.js'
|
||||||
export { buildSitemapEntries } from './buildSitemapEntries.js'
|
export { buildSitemapEntries } from './buildSitemapEntries.js'
|
||||||
@@ -18,3 +20,4 @@ export type { SiteMetaConfig } from './readSiteMetaConfig.js'
|
|||||||
export { buildSeoPlugin } from './seoPluginConfig.js'
|
export { buildSeoPlugin } from './seoPluginConfig.js'
|
||||||
export { slugsAcrossLocales } from './slugsAcrossLocales.js'
|
export { slugsAcrossLocales } from './slugsAcrossLocales.js'
|
||||||
export type { SeoMeta, SeoOption } from './types.js'
|
export type { SeoMeta, SeoOption } from './types.js'
|
||||||
|
export { validateFaviconField } from './validateFavicon.js'
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import type { FieldHook } from 'payload'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Field validation for the favicon upload: Google rejects favicons under 48×48,
|
||||||
|
* so warn the editor at save time if the uploaded icon is too small or not
|
||||||
|
* square. This ENFORCES the requirement instead of silently shipping a favicon
|
||||||
|
* Google won't display.
|
||||||
|
*
|
||||||
|
* Attach to the favicon field's validate (or as a beforeValidate hook on the
|
||||||
|
* Media relationship). Non-blocking by default — returns a warning string that
|
||||||
|
* Payload surfaces; make it throw if you want a hard block.
|
||||||
|
*
|
||||||
|
* Note: dimensions come from the related Media doc (Payload stores width/height
|
||||||
|
* for image uploads), so this checks the resolved upload, not the raw file.
|
||||||
|
*/
|
||||||
|
export const validateFaviconField: FieldHook = async ({ req, value }) => {
|
||||||
|
if (!value) {return value} // no favicon set → nothing to validate (optional field)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const media = await req.payload.findByID({
|
||||||
|
id: typeof value === 'object' ? (value as { id: string }).id : value,
|
||||||
|
collection: 'media',
|
||||||
|
depth: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const width = (media as { width?: number }).width
|
||||||
|
const height = (media as { height?: number }).height
|
||||||
|
const mimeType = (media as { mimeType?: string }).mimeType
|
||||||
|
|
||||||
|
// SVG scales infinitely — skip size checks.
|
||||||
|
if (mimeType === 'image/svg+xml') {return value}
|
||||||
|
|
||||||
|
if (typeof width === 'number' && typeof height === 'number') {
|
||||||
|
if (width < 48 || height < 48) {
|
||||||
|
req.payload.logger.warn(
|
||||||
|
`[ipal] Favicon is ${width}×${height}px. Google requires ≥48×48 to ` +
|
||||||
|
`display it in search results. Upload a larger square icon (96 or 192px).`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (width !== height) {
|
||||||
|
req.payload.logger.warn(
|
||||||
|
`[ipal] Favicon is not square (${width}×${height}). Use a square icon ` +
|
||||||
|
`so it isn't cropped in the browser tab or search results.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Media lookup failed — don't block the save over a validation warning.
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* Emits <link rel="preconnect"> + <link rel="dns-prefetch"> for the media CDN
|
||||||
|
* domain (R2_PUBLIC_URL), so the browser opens the TLS/DNS connection to the
|
||||||
|
* media host early — before it hits the first <img>. Saves ~150–300ms on the
|
||||||
|
* first image load.
|
||||||
|
*
|
||||||
|
* Reads the domain from R2_PUBLIC_URL (the same env var buildR2Storage uses),
|
||||||
|
* so there's ONE source of truth — no per-project hardcoded domain. Renders
|
||||||
|
* nothing when R2_PUBLIC_URL isn't set (local disk / no CDN → nothing to
|
||||||
|
* preconnect).
|
||||||
|
*
|
||||||
|
* Server Component — drop it in the <head> of your locale layout:
|
||||||
|
*
|
||||||
|
* import { MediaPreconnect } from '@intecion/ipal-kit/rsc'
|
||||||
|
* // in <head> (or top of <body> — Next hoists link tags):
|
||||||
|
* <MediaPreconnect />
|
||||||
|
*/
|
||||||
|
export function MediaPreconnect() {
|
||||||
|
const publicUrl = process.env.R2_PUBLIC_URL?.replace(/\/$/, '')
|
||||||
|
if (!publicUrl) {return null}
|
||||||
|
|
||||||
|
// Origin only (scheme + host) — preconnect targets an origin, not a path.
|
||||||
|
let origin: string
|
||||||
|
try {
|
||||||
|
origin = new URL(publicUrl).origin
|
||||||
|
} catch {
|
||||||
|
return null // malformed URL → skip rather than emit a broken tag
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<link crossOrigin="anonymous" href={origin} rel="preconnect" />
|
||||||
|
<link href={origin} rel="dns-prefetch" />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MediaPreconnect
|
||||||
@@ -38,32 +38,30 @@ export const buildR2Storage = (collections: string[] = ['media']): Plugin => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// s3Storage wants Record<string, true> (the literal true, per collection),
|
// Public URL for served media. R2 is private by default; its S3 endpoint only
|
||||||
// not Record<string, boolean>. Object.fromEntries widens true → boolean, so
|
// accepts uploads and won't serve files (403). With a custom domain
|
||||||
// build the map with an explicitly-typed accumulator to keep the literal.
|
|
||||||
const collectionsConfig: Record<string, true> = {}
|
|
||||||
for (const slug of collections) {
|
|
||||||
collectionsConfig[slug] = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Public URL for served media. R2 is private by default; with a custom domain
|
|
||||||
// (media.klient.pl → bucket) set R2_PUBLIC_URL so Payload generates URLs
|
// (media.klient.pl → bucket) set R2_PUBLIC_URL so Payload generates URLs
|
||||||
// pointing there instead of the private S3 endpoint (which 403s on the front).
|
// pointing there. Without it, uploads work but images don't display publicly.
|
||||||
// Without it, uploads work but images don't display publicly. See docs/storage.md.
|
// See docs/storage.md.
|
||||||
const publicUrl = process.env.R2_PUBLIC_URL?.replace(/\/$/, '') // strip trailing slash
|
const publicUrl = process.env.R2_PUBLIC_URL?.replace(/\/$/, '') // strip trailing slash
|
||||||
|
|
||||||
|
// generateFileURL is a PER-COLLECTION option in @payloadcms/storage-s3 (not a
|
||||||
|
// top-level one) — R2 needs it to point served URLs at the custom domain
|
||||||
|
// instead of the private S3 endpoint. Each collection gets either `true`
|
||||||
|
// (plain offload) or an object carrying generateFileURL when a public URL is set.
|
||||||
|
const generateFileURL = publicUrl
|
||||||
|
? ({ filename, prefix }: { filename: string; prefix?: string }) =>
|
||||||
|
[publicUrl, prefix, filename].filter(Boolean).join('/')
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const collectionsConfig: Record<string, { generateFileURL: typeof generateFileURL } | true> = {}
|
||||||
|
for (const slug of collections) {
|
||||||
|
collectionsConfig[slug] = generateFileURL ? { generateFileURL } : true
|
||||||
|
}
|
||||||
|
|
||||||
return s3Storage({
|
return s3Storage({
|
||||||
bucket,
|
bucket,
|
||||||
collections: collectionsConfig,
|
collections: collectionsConfig,
|
||||||
...(publicUrl
|
|
||||||
? {
|
|
||||||
// generateFileURL overrides the stored/returned URL to the CDN domain.
|
|
||||||
// Params come from Payload's storage plugin; type them explicitly since
|
|
||||||
// the callback shape isn't inferred here (would be implicit any).
|
|
||||||
generateFileURL: ({ filename, prefix }: { filename: string; prefix?: string }) =>
|
|
||||||
[publicUrl, prefix, filename].filter(Boolean).join('/'),
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
config: {
|
config: {
|
||||||
credentials: { accessKeyId, secretAccessKey },
|
credentials: { accessKeyId, secretAccessKey },
|
||||||
endpoint,
|
endpoint,
|
||||||
|
|||||||
Reference in New Issue
Block a user