SEO: favicon metadata, Organization JSON-LD, favicon validation; R2 media (preconnect, generateFileURL per-collection), filename normalization
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
import type { Field } from 'payload'
|
||||
|
||||
import { validateFaviconField } from '../../../modules/seo/index.js'
|
||||
|
||||
/**
|
||||
* 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[] = [
|
||||
{
|
||||
@@ -45,7 +47,8 @@ export const generalFields: Field[] = [
|
||||
name: 'logo',
|
||||
type: 'upload',
|
||||
admin: {
|
||||
description: 'Primary site logo.',
|
||||
description:
|
||||
'Primary site logo. Also used for Organization structured data (buildOrganizationJsonLd).',
|
||||
},
|
||||
relationTo: 'media',
|
||||
},
|
||||
@@ -61,7 +64,13 @@ export const generalFields: Field[] = [
|
||||
name: 'favicon',
|
||||
type: 'upload',
|
||||
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',
|
||||
},
|
||||
|
||||
@@ -99,6 +99,11 @@ export {
|
||||
} 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 type { PageMetadata, SeoMeta, SeoOption } from './modules/seo/index.js'
|
||||
export { buildHreflangAlternates, buildMetadata, composeTitle } from './modules/seo/index.js'
|
||||
export type { AutoFillMapping, RobotsRules, SitemapEntry } from './modules/seo/index.js'
|
||||
|
||||
@@ -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 type { AutoFillMapping } from './autoFillMeta.js'
|
||||
export { buildIconsMetadata } from './buildIconsMetadata.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 { buildSitemapEntries } from './buildSitemapEntries.js'
|
||||
@@ -18,3 +20,4 @@ export type { SiteMetaConfig } from './readSiteMetaConfig.js'
|
||||
export { buildSeoPlugin } from './seoPluginConfig.js'
|
||||
export { slugsAcrossLocales } from './slugsAcrossLocales.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
|
||||
}
|
||||
Reference in New Issue
Block a user