Compare commits

..
2 Commits
5 changed files with 123 additions and 102 deletions
+13 -13
View File
@@ -1,6 +1,6 @@
import type { BasePayload } from 'payload'; import type { BasePayload } from 'payload';
import type { ContentOption } from '../content/index.js';
import type { I18nConfig } from '../i18n/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`. * 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. * is the common, weaker kind.
*/ */
export type SitemapEntry = { export type SitemapEntry = {
url: string;
lastModified?: string | Date;
changeFrequency?: 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never';
priority?: number;
alternates?: { alternates?: {
languages: Record<string, string>; languages: Record<string, string>;
}; };
changeFrequency?: 'always' | 'daily' | 'hourly' | 'monthly' | 'never' | 'weekly' | 'yearly';
lastModified?: Date | string;
priority?: number;
url: string;
}; };
type BuildSitemapArgs = { type BuildSitemapArgs = {
payload: BasePayload;
config: I18nConfig;
/** Absolute origin, e.g. 'https://example.com'. Required for valid sitemap URLs. */ /** Absolute origin, e.g. 'https://example.com'. Required for valid sitemap URLs. */
baseUrl: string; baseUrl: string;
changeFrequency?: SitemapEntry['changeFrequency']; /** Pages collection slug. Defaults to 'pages'. */
config: I18nConfig; pagesSlug?: string;
/** Archive-backed collections, same value as the plugin option. */ /** Archive-backed collections, same value as the plugin option. */
content?: ContentOption; content?: ContentOption;
/** SiteSettings global slug. Defaults to 'site-settings'. */
settingsSlug?: string;
/** /**
* Slug of the page that is the site root (collapses to /{locale}). * Slug of the page that is the site root (collapses to /{locale}).
* Read from System Pages when omitted. * Read from System Pages when omitted.
*/ */
homeSlug?: string; homeSlug?: string;
/** Pages collection slug. Defaults to 'pages'. */ changeFrequency?: SitemapEntry['changeFrequency'];
pagesSlug?: string;
payload: BasePayload;
/** SiteSettings global slug. Defaults to 'site-settings'. */
settingsSlug?: string;
}; };
/** /**
* Collects every public URL — pages and archive entries — as sitemap entries * 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 {}; export {};
+46 -44
View File
@@ -1,13 +1,31 @@
import { archiveFieldName } from '../content/index.js'; import { getLocalizedSlugs } from '../i18n/index.js';
import { buildLocalizedPath, getLocalizedSlugs } from '../i18n/index.js'; import { buildLocalizedPath } from '../i18n/index.js';
import { buildHreflangAlternates } from './hreflang.js'; import { buildHreflangAlternates } from './hreflang.js';
/** Skip drafts and anything flagged noindex in the SEO tab. */ function isIndexable(doc) { import { archiveFieldName } from '../content/index.js';
if (doc._status && doc._status !== 'published') { /**
return false; * 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
if (doc.meta?.noindex) { * indexed. A sitemap should list only real, HTTP-200 content; a '/pl/404' entry
return false; * 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; return true;
} }
/** /**
@@ -17,26 +35,24 @@ import { buildHreflangAlternates } from './hreflang.js';
* every locale (including itself, per Google's guidance). * every locale (including itself, per Google's guidance).
*/ function entryFor(doc, locale, config, baseUrl, homeSlug, prefix, changeFrequency) { */ function entryFor(doc, locale, config, baseUrl, homeSlug, prefix, changeFrequency) {
const slugs = doc.slug && typeof doc.slug === 'object' ? getLocalizedSlugs({ const slugs = doc.slug && typeof doc.slug === 'object' ? getLocalizedSlugs({
config, slugField: doc.slug,
slugField: doc.slug config
}) : {}; }) : {};
const path = buildLocalizedPath({ const path = buildLocalizedPath({
slugs,
locale,
config, config,
homeSlug, homeSlug,
locale, prefix
prefix,
slugs
}); });
if (!path) { if (!path) return null;
return null;
}
const origin = baseUrl.replace(/\/$/, ''); const origin = baseUrl.replace(/\/$/, '');
const languages = buildHreflangAlternates({ const languages = buildHreflangAlternates({
baseUrl, slugs,
config, config,
baseUrl,
homeSlug, homeSlug,
prefix, prefix
slugs
}); });
return { return {
url: `${origin}${path}`, 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 locales = config.locales.map((l)=>l.code);
const defaultLocale = config.defaultLocale; const defaultLocale = config.defaultLocale;
// Resolve homeSlug and archive prefixes from System Pages (read once, in all // Resolve homeSlug and archive prefixes from System Pages (read once, in all
// locales so archive prefixes are available per language). // locales so archive prefixes are available per language).
const settings = await payload.findGlobal({ const settings = await payload.findGlobal({
slug: settingsSlug, slug: settingsSlug,
depth: 1, locale: 'all',
locale: 'all' depth: 1
}); });
const resolvedHomeSlug = homeSlug ?? extractSlugInLocale(settings.homepage, defaultLocale) ?? 'home'; const resolvedHomeSlug = homeSlug ?? extractSlugInLocale(settings.homepage, defaultLocale) ?? 'home';
// Which collections to walk: pages (no prefix) + each content collection with // Which collections to walk: pages (no prefix) + each content collection with
@@ -105,32 +121,24 @@ import { buildHreflangAlternates } from './hreflang.js';
// alternates without re-querying per locale. // alternates without re-querying per locale.
const result = await payload.find({ const result = await payload.find({
collection: collection.slug, collection: collection.slug,
locale: 'all',
depth: 0, depth: 0,
limit: 0, limit: 0,
locale: 'all',
pagination: false pagination: false
}); });
for (const raw of result.docs){ for (const raw of result.docs){
if (!isIndexable(raw)) { if (!isIndexable(raw)) continue;
continue;
}
// Emit the entry under the default locale's URL; alternates cover the rest. // Emit the entry under the default locale's URL; alternates cover the rest.
const entry = entryFor(raw, defaultLocale, config, baseUrl, resolvedHomeSlug, collection.prefixSlugs, changeFrequency); const entry = entryFor(raw, defaultLocale, config, baseUrl, resolvedHomeSlug, collection.prefixSlugs, changeFrequency);
if (entry) { if (entry) entries.push(entry);
entries.push(entry);
}
} }
} }
return entries; return entries;
} }
/** Pulls a slug string from a populated relationship in a specific locale. */ function extractSlugInLocale(rel, locale) { /** Pulls a slug string from a populated relationship in a specific locale. */ function extractSlugInLocale(rel, locale) {
if (!rel || typeof rel !== 'object') { if (!rel || typeof rel !== 'object') return undefined;
return undefined;
}
const slug = rel.slug; const slug = rel.slug;
if (typeof slug === 'string') { if (typeof slug === 'string') return slug;
return slug;
}
if (slug && typeof slug === 'object') { if (slug && typeof slug === 'object') {
const v = slug[locale]; const v = slug[locale];
return typeof v === 'string' ? v : undefined; return typeof v === 'string' ? v : undefined;
@@ -138,19 +146,13 @@ import { buildHreflangAlternates } from './hreflang.js';
return undefined; return undefined;
} }
/** Builds a locale→slug map from a populated archive relationship. */ function slugMapAllLocales(rel, locales) { /** Builds a locale→slug map from a populated archive relationship. */ function slugMapAllLocales(rel, locales) {
if (!rel || typeof rel !== 'object') { if (!rel || typeof rel !== 'object') return undefined;
return undefined;
}
const slug = rel.slug; const slug = rel.slug;
if (!slug || typeof slug !== 'object') { if (!slug || typeof slug !== 'object') return undefined;
return undefined;
}
const map = {}; const map = {};
for (const locale of locales){ for (const locale of locales){
const v = slug[locale]; const v = slug[locale];
if (typeof v === 'string') { if (typeof v === 'string') map[locale] = v;
map[locale] = v;
}
} }
return Object.keys(map).length ? map : undefined; return Object.keys(map).length ? map : undefined;
} }
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@intecion/ipal-kit", "name": "@intecion/ipal-kit",
"version": "1.2.0", "version": "1.2.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": {
+62 -43
View File
@@ -1,11 +1,10 @@
import type { BasePayload } from 'payload' import type { BasePayload } from 'payload'
import type { ContentOption } from '../content/index.js'
import type { I18nConfig } from '../i18n/index.js' import type { I18nConfig } from '../i18n/index.js'
import { getLocalizedSlugs } from '../i18n/index.js'
import { archiveFieldName } from '../content/index.js' import { buildLocalizedPath } from '../i18n/index.js'
import { buildLocalizedPath, getLocalizedSlugs } from '../i18n/index.js'
import { buildHreflangAlternates } from './hreflang.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`. * One sitemap entry, shaped for Next's `app/sitemap.ts`.
@@ -16,50 +15,70 @@ import { buildHreflangAlternates } from './hreflang.js'
* is the common, weaker kind. * is the common, weaker kind.
*/ */
export type SitemapEntry = { export type SitemapEntry = {
alternates?: { languages: Record<string, string> }
changeFrequency?: 'always' | 'daily' | 'hourly' | 'monthly' | 'never' | 'weekly' | 'yearly'
lastModified?: Date | string
priority?: number
url: string url: string
lastModified?: string | Date
changeFrequency?: 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never'
priority?: number
alternates?: { languages: Record<string, string> }
} }
type CollectionEntry = { type CollectionEntry = {
slug: string
/** Localized segment for entries (archive page slugs), when applicable. */ /** Localized segment for entries (archive page slugs), when applicable. */
prefixSlugs?: Record<string, string> prefixSlugs?: Record<string, string>
slug: string
} }
type BuildSitemapArgs = { type BuildSitemapArgs = {
payload: BasePayload
config: I18nConfig
/** Absolute origin, e.g. 'https://example.com'. Required for valid sitemap URLs. */ /** Absolute origin, e.g. 'https://example.com'. Required for valid sitemap URLs. */
baseUrl: string baseUrl: string
changeFrequency?: SitemapEntry['changeFrequency'] /** Pages collection slug. Defaults to 'pages'. */
config: I18nConfig pagesSlug?: string
/** Archive-backed collections, same value as the plugin option. */ /** Archive-backed collections, same value as the plugin option. */
content?: ContentOption content?: ContentOption
/** SiteSettings global slug. Defaults to 'site-settings'. */
settingsSlug?: string
/** /**
* Slug of the page that is the site root (collapses to /{locale}). * Slug of the page that is the site root (collapses to /{locale}).
* Read from System Pages when omitted. * Read from System Pages when omitted.
*/ */
homeSlug?: string homeSlug?: string
/** Pages collection slug. Defaults to 'pages'. */ changeFrequency?: SitemapEntry['changeFrequency']
pagesSlug?: string
payload: BasePayload
/** SiteSettings global slug. Defaults to 'site-settings'. */
settingsSlug?: string
} }
type DocRow = { type DocRow = {
_status?: string id: string | number
id: number | string
meta?: { noindex?: boolean } | null
slug?: unknown slug?: unknown
updatedAt?: string 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 { function isIndexable(doc: DocRow): boolean {
if (doc._status && doc._status !== 'published') {return false} if (doc._status && doc._status !== 'published') return false
if (doc.meta?.noindex) {return false} if (doc.meta?.noindex) return false
if (hasExcludedSlug(doc.slug)) return false
return true return true
} }
@@ -77,17 +96,17 @@ function entryFor(
homeSlug: string | undefined, homeSlug: string | undefined,
prefix: Record<string, string> | undefined, prefix: Record<string, string> | undefined,
changeFrequency: SitemapEntry['changeFrequency'], changeFrequency: SitemapEntry['changeFrequency'],
): null | SitemapEntry { ): SitemapEntry | null {
const slugs = const slugs =
doc.slug && typeof doc.slug === 'object' 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 }) const path = buildLocalizedPath({ slugs, locale, config, homeSlug, prefix })
if (!path) {return null} if (!path) return null
const origin = baseUrl.replace(/\/$/, '') const origin = baseUrl.replace(/\/$/, '')
const languages = buildHreflangAlternates({ baseUrl, config, homeSlug, prefix, slugs }) const languages = buildHreflangAlternates({ slugs, config, baseUrl, homeSlug, prefix })
return { return {
url: `${origin}${path}`, url: `${origin}${path}`,
@@ -119,14 +138,14 @@ function entryFor(
* ``` * ```
*/ */
export async function buildSitemapEntries({ export async function buildSitemapEntries({
baseUrl,
changeFrequency = 'weekly',
config,
content,
homeSlug,
pagesSlug = 'pages',
payload, payload,
config,
baseUrl,
pagesSlug = 'pages',
content,
settingsSlug = 'site-settings', settingsSlug = 'site-settings',
homeSlug,
changeFrequency = 'weekly',
}: BuildSitemapArgs): Promise<SitemapEntry[]> { }: BuildSitemapArgs): Promise<SitemapEntry[]> {
const locales = config.locales.map((l) => l.code) const locales = config.locales.map((l) => l.code)
const defaultLocale = config.defaultLocale const defaultLocale = config.defaultLocale
@@ -135,8 +154,8 @@ export async function buildSitemapEntries({
// locales so archive prefixes are available per language). // locales so archive prefixes are available per language).
const settings = (await payload.findGlobal({ const settings = (await payload.findGlobal({
slug: settingsSlug as never, slug: settingsSlug as never,
depth: 1,
locale: 'all' as never, locale: 'all' as never,
depth: 1,
})) as Record<string, unknown> })) as Record<string, unknown>
const resolvedHomeSlug = const resolvedHomeSlug =
@@ -159,14 +178,14 @@ export async function buildSitemapEntries({
// alternates without re-querying per locale. // alternates without re-querying per locale.
const result = await payload.find({ const result = await payload.find({
collection: collection.slug as never, collection: collection.slug as never,
locale: 'all' as never,
depth: 0, depth: 0,
limit: 0, // no pagination — sitemap wants everything limit: 0, // no pagination — sitemap wants everything
locale: 'all' as never,
pagination: false as never, pagination: false as never,
}) })
for (const raw of result.docs as DocRow[]) { for (const raw of result.docs as DocRow[]) {
if (!isIndexable(raw)) {continue} if (!isIndexable(raw)) continue
// Emit the entry under the default locale's URL; alternates cover the rest. // Emit the entry under the default locale's URL; alternates cover the rest.
const entry = entryFor( const entry = entryFor(
@@ -178,7 +197,7 @@ export async function buildSitemapEntries({
collection.prefixSlugs, collection.prefixSlugs,
changeFrequency, 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. */ /** Pulls a slug string from a populated relationship in a specific locale. */
function extractSlugInLocale(rel: unknown, locale: string): string | undefined { 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 const slug = (rel as { slug?: unknown }).slug
if (typeof slug === 'string') {return slug} if (typeof slug === 'string') return slug
if (slug && typeof slug === 'object') { if (slug && typeof slug === 'object') {
const v = (slug as Record<string, unknown>)[locale] const v = (slug as Record<string, unknown>)[locale]
return typeof v === 'string' ? v : undefined 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. */ /** Builds a locale→slug map from a populated archive relationship. */
function slugMapAllLocales(rel: unknown, locales: string[]): Record<string, string> | undefined { 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 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> = {} const map: Record<string, string> = {}
for (const locale of locales) { for (const locale of locales) {
const v = (slug as Record<string, unknown>)[locale] 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 return Object.keys(map).length ? map : undefined
} }