Compare commits

...
2 Commits
Author SHA1 Message Date
radoslaw.smolinski 4a46651839 1.4.2 2026-09-28 14:47:19 +02:00
radoslaw.smolinski e7f06548fb Added styling for sitemap generator 2026-09-28 14:47:13 +02:00
6 changed files with 260 additions and 173 deletions
+27 -13
View File
@@ -1,14 +1,25 @@
import type { SitemapEntry } from './buildSitemapEntries.js'; import type { SitemapEntry } from './buildSitemapEntries.js';
type BuildSitemapXmlOptions = {
/**
* URL of a CSS stylesheet to make the sitemap readable in the browser, e.g.
* '/sitemap.css'. Uses `type="text/css"` — the W3C "Associating Style Sheets
* with XML" mechanism, which is NOT deprecated (unlike XSLT / type="text/xsl",
* which Chrome/WebKit are removing). CSS on XML shows no warning, styles the
* raw tags directly (e.g. `url { display: block }` turns the wall of text into
* cards), and crawlers ignore the directive entirely.
*/
cssUrl?: string;
};
/** /**
* Serializes sitemap entries to a clean, indented XML STRING — valid for crawlers * Serializes sitemap entries to a clean, indented XML STRING — valid for crawlers
* and readable in the browser as the native XML tree (indented, collapsible, * and (with `cssUrl`) styled in the browser via plain CSS.
* syntax-highlighted by the browser itself).
* *
* NO XSLT stylesheet: browsers (Chrome et al.) are REMOVING XSLT support, so a * Two viewing modes:
* <?xml-stylesheet?> approach is a dead end — it shows a deprecation warning now * - No cssUrl → the browser's native formatted XML tree (indented, collapsible).
* and will break soon. Instead we serve plain XML with the correct Content-Type; * - With cssUrl → a `<?xml-stylesheet type="text/css">` directive; the project's
* the browser renders its built-in formatted XML view (the "code tree" look) with * CSS styles the XML tags (cards, labels via ::before). NOT XSLT — that's being
* no transformation needed. * removed from browsers and shows a deprecation warning. CSS is safe and
* W3C-standard.
* *
* // app/sitemap.xml/route.ts * // app/sitemap.xml/route.ts
* import { buildSitemapXml } from '@intecion/ipal-kit' * import { buildSitemapXml } from '@intecion/ipal-kit'
@@ -16,16 +27,19 @@ import type { SitemapEntry } from './buildSitemapEntries.js';
* export const dynamic = 'force-dynamic' * export const dynamic = 'force-dynamic'
* export async function GET() { * export async function GET() {
* const entries = await sitemap() * const entries = await sitemap()
* const xml = buildSitemapXml(entries) * const xml = buildSitemapXml(entries, { cssUrl: '/sitemap.css' })
* return new Response(xml, { * return new Response(xml, {
* headers: { 'Content-Type': 'application/xml; charset=utf-8' }, * headers: { 'Content-Type': 'application/xml; charset=utf-8' },
* }) * })
* } * }
* *
* The indentation makes the raw XML pleasant to read; the browser's default XML * Put sitemap.css in the project's /public and style the tags (see docs/seo.md
* viewer adds the tree/fold UI. Crawlers read the XML as usual. * for a starter). Note: <loc> is an XML tag, not <a href> — CSS can't make it a
* clickable link (some browsers auto-detect URLs); the win is readability, not
* clickability.
* *
* NOTE: if you use this custom route, DON'T also keep app/sitemap.ts — pick one * NOTE: if you use this custom route, DON'T also keep app/sitemap.ts — pick one.
* (this route OR the Next MetadataRoute). Two sitemaps confuse crawlers. * Two sitemaps confuse crawlers.
*/ */
export declare function buildSitemapXml(entries: SitemapEntry[]): string; export declare function buildSitemapXml(entries: SitemapEntry[], opts?: BuildSitemapXmlOptions): string;
export {};
+20 -20
View File
@@ -1,13 +1,13 @@
/** /**
* Serializes sitemap entries to a clean, indented XML STRING — valid for crawlers * Serializes sitemap entries to a clean, indented XML STRING — valid for crawlers
* and readable in the browser as the native XML tree (indented, collapsible, * and (with `cssUrl`) styled in the browser via plain CSS.
* syntax-highlighted by the browser itself).
* *
* NO XSLT stylesheet: browsers (Chrome et al.) are REMOVING XSLT support, so a * Two viewing modes:
* <?xml-stylesheet?> approach is a dead end — it shows a deprecation warning now * - No cssUrl → the browser's native formatted XML tree (indented, collapsible).
* and will break soon. Instead we serve plain XML with the correct Content-Type; * - With cssUrl → a `<?xml-stylesheet type="text/css">` directive; the project's
* the browser renders its built-in formatted XML view (the "code tree" look) with * CSS styles the XML tags (cards, labels via ::before). NOT XSLT — that's being
* no transformation needed. * removed from browsers and shows a deprecation warning. CSS is safe and
* W3C-standard.
* *
* // app/sitemap.xml/route.ts * // app/sitemap.xml/route.ts
* import { buildSitemapXml } from '@intecion/ipal-kit' * import { buildSitemapXml } from '@intecion/ipal-kit'
@@ -15,18 +15,21 @@
* export const dynamic = 'force-dynamic' * export const dynamic = 'force-dynamic'
* export async function GET() { * export async function GET() {
* const entries = await sitemap() * const entries = await sitemap()
* const xml = buildSitemapXml(entries) * const xml = buildSitemapXml(entries, { cssUrl: '/sitemap.css' })
* return new Response(xml, { * return new Response(xml, {
* headers: { 'Content-Type': 'application/xml; charset=utf-8' }, * headers: { 'Content-Type': 'application/xml; charset=utf-8' },
* }) * })
* } * }
* *
* The indentation makes the raw XML pleasant to read; the browser's default XML * Put sitemap.css in the project's /public and style the tags (see docs/seo.md
* viewer adds the tree/fold UI. Crawlers read the XML as usual. * for a starter). Note: <loc> is an XML tag, not <a href> — CSS can't make it a
* clickable link (some browsers auto-detect URLs); the win is readability, not
* clickability.
* *
* NOTE: if you use this custom route, DON'T also keep app/sitemap.ts — pick one * NOTE: if you use this custom route, DON'T also keep app/sitemap.ts — pick one.
* (this route OR the Next MetadataRoute). Two sitemaps confuse crawlers. * Two sitemaps confuse crawlers.
*/ export function buildSitemapXml(entries) { */ export function buildSitemapXml(entries, opts = {}) {
const { cssUrl } = opts;
const esc = (s)=>s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&apos;'); const esc = (s)=>s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&apos;');
const urls = entries.map((e)=>{ const urls = entries.map((e)=>{
const parts = [ const parts = [
@@ -36,12 +39,8 @@
const iso = e.lastModified instanceof Date ? e.lastModified.toISOString() : String(e.lastModified); const iso = e.lastModified instanceof Date ? e.lastModified.toISOString() : String(e.lastModified);
parts.push(` <lastmod>${esc(iso)}</lastmod>`); parts.push(` <lastmod>${esc(iso)}</lastmod>`);
} }
if (e.changeFrequency) { if (e.changeFrequency) parts.push(` <changefreq>${e.changeFrequency}</changefreq>`);
parts.push(` <changefreq>${e.changeFrequency}</changefreq>`); if (typeof e.priority === 'number') parts.push(` <priority>${e.priority}</priority>`);
}
if (typeof e.priority === 'number') {
parts.push(` <priority>${e.priority}</priority>`);
}
const alternates = e.alternates?.languages; const alternates = e.alternates?.languages;
if (alternates) { if (alternates) {
for (const [lang, href] of Object.entries(alternates)){ for (const [lang, href] of Object.entries(alternates)){
@@ -52,7 +51,8 @@
} }
return ` <url>\n${parts.join('\n')}\n </url>`; return ` <url>\n${parts.join('\n')}\n </url>`;
}).join('\n'); }).join('\n');
return `<?xml version="1.0" encoding="UTF-8"?>\n` + `<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" ` + `xmlns:xhtml="http://www.w3.org/1999/xhtml">\n` + urls + `\n</urlset>`; const stylesheet = cssUrl ? `<?xml-stylesheet type="text/css" href="${esc(cssUrl)}"?>\n` : '';
return `<?xml version="1.0" encoding="UTF-8"?>\n` + stylesheet + `<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" ` + `xmlns:xhtml="http://www.w3.org/1999/xhtml">\n` + urls + `\n</urlset>`;
} }
//# sourceMappingURL=buildSitemapXml.js.map //# sourceMappingURL=buildSitemapXml.js.map
File diff suppressed because one or more lines are too long
+174 -123
View File
@@ -19,14 +19,14 @@ admina i bez importMap się nie wyrenderują.
```ts ```ts
ipalKit({ ipalKit({
seo: { seo: {
collections: ['pages', 'posts'], // które kolekcje dostają meta collections: ['pages', 'posts'], // które kolekcje dostają meta
// generateTitle: ({ doc }) => `${doc.title}`, // opcjonalne // generateTitle: ({ doc }) => `${doc.title}`, // opcjonalne
// generateDescription: ({ doc }) => doc.excerpt ?? '', // generateDescription: ({ doc }) => doc.excerpt ?? '',
// fields: [...], // extra pola w grupie SEO // fields: [...], // extra pola w grupie SEO
// autoFill: { title: 'title', description: 'excerpt' }, // mapowanie auto-fill // autoFill: { title: 'title', description: 'excerpt' }, // mapowanie auto-fill
// autoFill: false, // wyłącz auto-fill // autoFill: false, // wyłącz auto-fill
}, },
}) })
``` ```
@@ -89,16 +89,16 @@ import { createPageMetadata } from '@intecion/ipal-kit'
import { i18nConfig } from '@/i18n.config' import { i18nConfig } from '@/i18n.config'
const pageMetadata = createPageMetadata({ const pageMetadata = createPageMetadata({
config: i18nConfig, config: i18nConfig,
baseUrl: process.env.NEXT_PUBLIC_SERVER_URL, baseUrl: process.env.NEXT_PUBLIC_SERVER_URL,
// collection: 'pages', // domyślne // collection: 'pages', // domyślne
// settingsSlug: 'site-settings', // domyślne // settingsSlug: 'site-settings', // domyślne
// siteNameField: 'siteName', // domyślne // siteNameField: 'siteName', // domyślne
}) })
export async function generateMetadata({ params }): Promise<Metadata> { export async function generateMetadata({ params }): Promise<Metadata> {
const { locale, slug } = await params const { locale, slug } = await params
return pageMetadata({ payload: await getPayload({ config }), locale, slug }) return pageMetadata({ payload: await getPayload({ config }), locale, slug })
} }
``` ```
@@ -121,43 +121,43 @@ global. Klient dostarcza resolvery.
import { createMetadataGenerator } from '@intecion/ipal-kit' import { createMetadataGenerator } from '@intecion/ipal-kit'
const generate = createMetadataGenerator({ const generate = createMetadataGenerator({
config: i18nConfig, config: i18nConfig,
baseUrl: process.env.NEXT_PUBLIC_SERVER_URL, baseUrl: process.env.NEXT_PUBLIC_SERVER_URL,
homeSlug: 'homepage', homeSlug: 'homepage',
resolveDocument: async ({ payload, params, locale }) => { resolveDocument: async ({ payload, params, locale }) => {
const slug = (params.slug as string[])?.join('/') const slug = (params.slug as string[])?.join('/')
// meta MUSI przyjść w konkretnym locale (stringi), a slug jako mapa // meta MUSI przyjść w konkretnym locale (stringi), a slug jako mapa
// locale→wartość (hreflang) — to dwa różne odczyty. // locale→wartość (hreflang) — to dwa różne odczyty.
const found = await payload.find({ const found = await payload.find({
collection: 'posts', collection: 'posts',
where: { slug: { equals: slug } }, where: { slug: { equals: slug } },
locale, locale,
depth: 1, depth: 1,
limit: 1, limit: 1,
}) })
const doc = found.docs[0] const doc = found.docs[0]
if (!doc) return null if (!doc) return null
const allLocales = await payload.findByID({ const allLocales = await payload.findByID({
collection: 'posts', collection: 'posts',
id: doc.id, id: doc.id,
locale: 'all', locale: 'all',
depth: 0, depth: 0,
}) })
return { ...doc, slug: allLocales.slug } return { ...doc, slug: allLocales.slug }
}, },
resolveSiteName: async ({ payload, locale }) => resolveSiteName: async ({ payload, locale }) =>
(await getSiteSettings(payload, { locale })).siteName ?? null, (await getSiteSettings(payload, { locale })).siteName ?? null,
resolveImageUrl: async ({ doc }) => doc.meta?.image?.url ?? null, resolveImageUrl: async ({ doc }) => doc.meta?.image?.url ?? null,
}) })
export async function generateMetadata({ params }) { export async function generateMetadata({ params }) {
const { locale, slug } = await params const { locale, slug } = await params
return generate({ payload: await getPayload({ config }), params: { slug }, locale }) return generate({ payload: await getPayload({ config }), params: { slug }, locale })
} }
``` ```
@@ -176,16 +176,16 @@ Gdy chcesz pełną kontrolę:
import { buildMetadata, getLocalizedSlugs } from '@intecion/ipal-kit' import { buildMetadata, getLocalizedSlugs } from '@intecion/ipal-kit'
return buildMetadata({ return buildMetadata({
meta: doc.meta, // z plugin-seo meta: doc.meta, // z plugin-seo
siteName: settings.siteName, siteName: settings.siteName,
imageUrl: '/og.png', imageUrl: '/og.png',
locale: 'pl', locale: 'pl',
slugs: getLocalizedSlugs({ slugField: docAllLocales.slug, config }), slugs: getLocalizedSlugs({ slugField: docAllLocales.slug, config }),
config, config,
baseUrl: 'https://example.com', baseUrl: 'https://example.com',
separator: ' – ', // opcjonalne separator: ' – ', // opcjonalne
order: 'site-first', // opcjonalne order: 'site-first', // opcjonalne
homeSlug: 'homepage', homeSlug: 'homepage',
}) })
// → { title, description, openGraph, alternates: { canonical, languages } } // → { title, description, openGraph, alternates: { canonical, languages } }
``` ```
@@ -232,10 +232,10 @@ nie rozjedzie się z tym, co strony serwują. Handlery są gotowe w
```ts ```ts
// src/lib/content.ts // src/lib/content.ts
export const { /* ... */, sitemap, robots } = createContentHelpers({ export const { /* ... */, sitemap, robots } = createContentHelpers({
config, config,
content: contentConfig, content: contentConfig,
i18n: i18nConfig, // wymagane dla sitemap (hreflang) i18n: i18nConfig, // wymagane dla sitemap (hreflang)
baseUrl: process.env.NEXT_PUBLIC_SERVER_URL, baseUrl: process.env.NEXT_PUBLIC_SERVER_URL,
}) })
``` ```
@@ -278,7 +278,7 @@ Niskopoziomowo (własna trasa zamiast handlera z fabryki):
import { buildSitemapEntries, buildRobots } from '@intecion/ipal-kit' import { buildSitemapEntries, buildRobots } from '@intecion/ipal-kit'
const entries = await buildSitemapEntries({ const entries = await buildSitemapEntries({
payload, config: i18nConfig, baseUrl, content: contentConfig, payload, config: i18nConfig, baseUrl, content: contentConfig,
}) })
``` ```
@@ -321,9 +321,9 @@ import { buildIconsMetadata } from '@intecion/ipal-kit'
import { getSettings } from '@/lib/payload' import { getSettings } from '@/lib/payload'
export async function generateMetadata({ params }): Promise<Metadata> { export async function generateMetadata({ params }): Promise<Metadata> {
const { locale } = await params const { locale } = await params
const settings = await getSettings(locale) const settings = await getSettings(locale)
return buildIconsMetadata(settings.favicon) // z pola favicon (panel) return buildIconsMetadata(settings.favicon) // z pola favicon (panel)
} }
``` ```
@@ -346,16 +346,16 @@ wynikach, logo w knowledge panel.
import { buildOrganizationJsonLd } from '@intecion/ipal-kit' import { buildOrganizationJsonLd } from '@intecion/ipal-kit'
const jsonLd = buildOrganizationJsonLd({ const jsonLd = buildOrganizationJsonLd({
name: settings.siteName, name: settings.siteName,
url: process.env.NEXT_PUBLIC_SERVER_URL!, url: process.env.NEXT_PUBLIC_SERVER_URL!,
logo: settings.logo, logo: settings.logo,
sameAs: settings.socialLinks, // opcjonalne: profile społecznościowe sameAs: settings.socialLinks, // opcjonalne: profile społecznościowe
}) })
// w JSX layoutu: // w JSX layoutu:
<script <script
type="application/ld+json" type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/> />
``` ```
@@ -403,42 +403,42 @@ import { i18nConfig } from '@/i18n.config'
import type { SiteSetting } from '@/payload-types' import type { SiteSetting } from '@/payload-types'
export default async function manifest(): Promise<MetadataRoute.Manifest> { export default async function manifest(): Promise<MetadataRoute.Manifest> {
const payload = await getCachedPayload() const payload = await getCachedPayload()
const settings = await getSiteSettings<SiteSetting>(payload, { const settings = await getSiteSettings<SiteSetting>(payload, {
locale: i18nConfig.defaultLocale as never, locale: i18nConfig.defaultLocale as never,
}) })
const siteName = settings?.siteName?.trim() const siteName = settings?.siteName?.trim()
// Ikona z panelu (favicon → logo). Dla PNG podaj KONKRETNY rozmiar z media // Ikona z panelu (favicon → logo). Dla PNG podaj KONKRETNY rozmiar z media
// (nie 'any' — 'any' jest tylko dla SVG). Bez ikony → pomiń pole icons. // (nie 'any' — 'any' jest tylko dla SVG). Bez ikony → pomiń pole icons.
const icon = settings?.favicon ?? settings?.logo const icon = settings?.favicon ?? settings?.logo
const iconEntry = const iconEntry =
typeof icon === 'object' && icon?.url typeof icon === 'object' && icon?.url
? (() => { ? (() => {
const isSvg = icon.mimeType === 'image/svg+xml' || icon.url.endsWith('.svg') const isSvg = icon.mimeType === 'image/svg+xml' || icon.url.endsWith('.svg')
const size = const size =
typeof icon.width === 'number' && typeof icon.height === 'number' typeof icon.width === 'number' && typeof icon.height === 'number'
? `${Math.min(icon.width, icon.height)}x${Math.min(icon.width, icon.height)}` ? `${Math.min(icon.width, icon.height)}x${Math.min(icon.width, icon.height)}`
: '512x512' : '512x512'
return { return {
src: icon.url, src: icon.url,
type: icon.mimeType ?? 'image/png', type: icon.mimeType ?? 'image/png',
sizes: isSvg ? 'any' : size, // 'any' tylko dla SVG sizes: isSvg ? 'any' : size, // 'any' tylko dla SVG
} }
})() })()
: undefined : undefined
// Buduj TYLKO z tego, co jest. Brak pola → nie ma go w manifeście (zamiast // Buduj TYLKO z tego, co jest. Brak pola → nie ma go w manifeście (zamiast
// zaszytego fallbacku). start_url z configu, nie zaszyte '/pl'. // zaszytego fallbacku). start_url z configu, nie zaszyte '/pl'.
return { return {
...(siteName ? { name: siteName, short_name: siteName } : {}), ...(siteName ? { name: siteName, short_name: siteName } : {}),
start_url: `/${i18nConfig.defaultLocale}`, start_url: `/${i18nConfig.defaultLocale}`,
display: 'standalone', display: 'standalone',
...(iconEntry ? { icons: [iconEntry] } : {}), ...(iconEntry ? { icons: [iconEntry] } : {}),
// theme_color / background_color / description — TYLKO jeśli dodasz pola w // theme_color / background_color / description — TYLKO jeśli dodasz pola w
// panelu i je odczytasz. NIE zaszywaj '#0e1e24' ani opisu klienta. // panelu i je odczytasz. NIE zaszywaj '#0e1e24' ani opisu klienta.
} }
} }
``` ```
@@ -522,16 +522,16 @@ zanim strona wygeneruje metadane. To pcha metadata do body. NIE deklaruj `<head>
```tsx ```tsx
// ŹLE — jawny <head> zamyka head za wcześnie // ŹLE — jawny <head> zamyka head za wcześnie
<html lang={locale}> <html lang={locale}>
<head><MediaPreconnect /></head> <head><MediaPreconnect /></head>
<body>{children}</body> <body>{children}</body>
</html> </html>
// DOBRZE — MediaPreconnect w body, React 19 hoistuje link do head // DOBRZE — MediaPreconnect w body, React 19 hoistuje link do head
<html lang={locale}> <html lang={locale}>
<body> <body>
<MediaPreconnect /> <MediaPreconnect />
{children} {children}
</body> </body>
</html> </html>
``` ```
@@ -546,8 +546,8 @@ z jakiegoś powodu nie może być ISR):
```ts ```ts
// next.config.ts // next.config.ts
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
htmlLimitedBots: htmlLimitedBots:
/Googlebot|Google-InspectionTool|Storebot-Google|Bingbot|Yandex|DuckDuckBot|Baiduspider|Screaming Frog|AhrefsBot|SemrushBot/i, /Googlebot|Google-InspectionTool|Storebot-Google|Bingbot|Yandex|DuckDuckBot|Baiduspider|Screaming Frog|AhrefsBot|SemrushBot/i,
} }
``` ```
@@ -652,10 +652,10 @@ searchbox (pole wyszukiwania pod wynikiem marki). RAZ w root layout:
```tsx ```tsx
import { buildWebSiteJsonLd } from '@intecion/ipal-kit' import { buildWebSiteJsonLd } from '@intecion/ipal-kit'
const jsonLd = buildWebSiteJsonLd({ const jsonLd = buildWebSiteJsonLd({
name: settings.siteName, name: settings.siteName,
url: baseUrl, url: baseUrl,
// TYLKO jeśli masz działającą stronę wyszukiwania: // TYLKO jeśli masz działającą stronę wyszukiwania:
search: { target: `${baseUrl}/szukaj?q={search_term_string}` }, search: { target: `${baseUrl}/szukaj?q={search_term_string}` },
}) })
``` ```
Pomiń `search`, jeśli nie ma realnej wyszukiwarki — SearchAction wskazujący na Pomiń `search`, jeśli nie ma realnej wyszukiwarki — SearchAction wskazujący na
@@ -666,9 +666,9 @@ Detailing) + Google rozumie hierarchię. PER STRONA, z pozycji strony:
```tsx ```tsx
import { buildBreadcrumbJsonLd } from '@intecion/ipal-kit' import { buildBreadcrumbJsonLd } from '@intecion/ipal-kit'
const jsonLd = buildBreadcrumbJsonLd([ const jsonLd = buildBreadcrumbJsonLd([
{ name: 'Strona główna', url: `${base}/pl` }, { name: 'Strona główna', url: `${base}/pl` },
{ name: 'Usługi', url: `${base}/pl/uslugi` }, { name: 'Usługi', url: `${base}/pl/uslugi` },
{ name: 'Detailing', url: `${base}/pl/uslugi/detailing` }, { name: 'Detailing', url: `${base}/pl/uslugi/detailing` },
]) ])
``` ```
Okruszki buduj z RZECZYWISTEJ pozycji strony (resolveRoute / ścieżka URL), NIE z Okruszki buduj z RZECZYWISTEJ pozycji strony (resolveRoute / ścieżka URL), NIE z
@@ -679,7 +679,7 @@ samych pozycji co menu w headerze:
```tsx ```tsx
import { buildSiteNavigationJsonLd } from '@intecion/ipal-kit' import { buildSiteNavigationJsonLd } from '@intecion/ipal-kit'
const jsonLd = buildSiteNavigationJsonLd( const jsonLd = buildSiteNavigationJsonLd(
navItems.map(i => ({ name: i.label, url: `${base}${i.href}` })) navItems.map(i => ({ name: i.label, url: `${base}${i.href}` }))
) )
``` ```
Dane z tego samego źródła co widoczne menu — nie osobna zaszyta lista. Dane z tego samego źródła co widoczne menu — nie osobna zaszyta lista.
@@ -914,9 +914,60 @@ export async function GET() {
} }
``` ```
`buildSitemapXml` zwraca wcięty XML (czytelny w surowej postaci), a przeglądarka `buildSitemapXml` zwraca wcięty XML. Dwa tryby wyświetlania:
dokłada widok drzewa. Zawiera hreflang jako `<xhtml:link>`. Crawlery czytają XML
normalnie. **Bez `cssUrl`** → przeglądarka pokazuje natywny widok drzewa XML (wcięcia, zwijanie).
**Z `cssUrl`** → stylujesz XML własnym CSS (kafelki, etykiety). To W3C standard
„Associating Style Sheets with XML" — `type="text/css"`, NIE wycofywane (w
przeciwieństwie do XSLT/`text/xsl`). Zero ostrzeżenia, ładny wygląd, bezpieczne
dla Google (crawlery ignorują dyrektywę).
```ts
const xml = buildSitemapXml(entries, { cssUrl: '/sitemap.css' })
```
### Starter CSS (public/sitemap.css)
Skopiuj do `public/sitemap.css` w projekcie, dostosuj do designu. Selektory to
bezpośrednio nazwy tagów XML:
```css
/* public/sitemap.css */
urlset {
display: block;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #090d16; color: #f1f5f9;
padding: 2rem 1.5rem; max-width: 1200px; margin: 0 auto;
}
url { /* każdy adres jako kafelek */
display: block;
background: #111827; border: 1px solid #1e293b; border-radius: 8px;
padding: 1rem 1.25rem; margin-bottom: 0.75rem;
}
loc { /* adres URL */
display: block; font-size: 0.95rem; font-weight: 600;
color: #f97316; margin-bottom: 0.5rem; word-break: break-all;
}
lastmod, changefreq, priority {
display: inline-block; font-size: 0.8rem; color: #94a3b8; margin-right: 1.5rem;
}
lastmod::before { content: 'Ostatnia modyfikacja: '; color: #64748b; }
changefreq::before { content: 'Częstotliwość: '; color: #64748b; }
priority::before { content: 'Priorytet: '; color: #64748b; }
link { /* tagi hreflang */
display: inline-block; font-size: 0.75rem;
background: #1e293b; color: #38bdf8; border: 1px solid #334155;
padding: 0.15rem 0.45rem; border-radius: 4px; margin: 0.4rem 0.35rem 0 0;
}
```
Kluczowe: `display: block` na `<url>`/`<loc>` zamienia „ścianę tekstu" w kafelki.
Etykiety („Ostatnia modyfikacja:") przez `::before`. Dostosuj kolory do projektu.
**Ograniczenie:** `<loc>` to tag XML, nie `<a href>` — CSS nie zrobi z niego
klikalnego linku (niektóre przeglądarki autodetektują URL). Zysk to czytelność
i organizacja, nie klikalność. Dla sitemap (głównie dla robotów) to akceptowalne.
### WAŻNE — jeden sitemap, nie dwa ### WAŻNE — jeden sitemap, nie dwa
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@intecion/ipal-kit", "name": "@intecion/ipal-kit",
"version": "1.4.1", "version": "1.4.2",
"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": {
+37 -15
View File
@@ -1,15 +1,27 @@
import type { SitemapEntry } from './buildSitemapEntries.js' import type { SitemapEntry } from './buildSitemapEntries.js'
type BuildSitemapXmlOptions = {
/**
* URL of a CSS stylesheet to make the sitemap readable in the browser, e.g.
* '/sitemap.css'. Uses `type="text/css"` — the W3C "Associating Style Sheets
* with XML" mechanism, which is NOT deprecated (unlike XSLT / type="text/xsl",
* which Chrome/WebKit are removing). CSS on XML shows no warning, styles the
* raw tags directly (e.g. `url { display: block }` turns the wall of text into
* cards), and crawlers ignore the directive entirely.
*/
cssUrl?: string
}
/** /**
* Serializes sitemap entries to a clean, indented XML STRING — valid for crawlers * Serializes sitemap entries to a clean, indented XML STRING — valid for crawlers
* and readable in the browser as the native XML tree (indented, collapsible, * and (with `cssUrl`) styled in the browser via plain CSS.
* syntax-highlighted by the browser itself).
* *
* NO XSLT stylesheet: browsers (Chrome et al.) are REMOVING XSLT support, so a * Two viewing modes:
* <?xml-stylesheet?> approach is a dead end — it shows a deprecation warning now * - No cssUrl → the browser's native formatted XML tree (indented, collapsible).
* and will break soon. Instead we serve plain XML with the correct Content-Type; * - With cssUrl → a `<?xml-stylesheet type="text/css">` directive; the project's
* the browser renders its built-in formatted XML view (the "code tree" look) with * CSS styles the XML tags (cards, labels via ::before). NOT XSLT — that's being
* no transformation needed. * removed from browsers and shows a deprecation warning. CSS is safe and
* W3C-standard.
* *
* // app/sitemap.xml/route.ts * // app/sitemap.xml/route.ts
* import { buildSitemapXml } from '@intecion/ipal-kit' * import { buildSitemapXml } from '@intecion/ipal-kit'
@@ -17,19 +29,26 @@ import type { SitemapEntry } from './buildSitemapEntries.js'
* export const dynamic = 'force-dynamic' * export const dynamic = 'force-dynamic'
* export async function GET() { * export async function GET() {
* const entries = await sitemap() * const entries = await sitemap()
* const xml = buildSitemapXml(entries) * const xml = buildSitemapXml(entries, { cssUrl: '/sitemap.css' })
* return new Response(xml, { * return new Response(xml, {
* headers: { 'Content-Type': 'application/xml; charset=utf-8' }, * headers: { 'Content-Type': 'application/xml; charset=utf-8' },
* }) * })
* } * }
* *
* The indentation makes the raw XML pleasant to read; the browser's default XML * Put sitemap.css in the project's /public and style the tags (see docs/seo.md
* viewer adds the tree/fold UI. Crawlers read the XML as usual. * for a starter). Note: <loc> is an XML tag, not <a href> — CSS can't make it a
* clickable link (some browsers auto-detect URLs); the win is readability, not
* clickability.
* *
* NOTE: if you use this custom route, DON'T also keep app/sitemap.ts — pick one * NOTE: if you use this custom route, DON'T also keep app/sitemap.ts — pick one.
* (this route OR the Next MetadataRoute). Two sitemaps confuse crawlers. * Two sitemaps confuse crawlers.
*/ */
export function buildSitemapXml(entries: SitemapEntry[]): string { export function buildSitemapXml(
entries: SitemapEntry[],
opts: BuildSitemapXmlOptions = {},
): string {
const { cssUrl } = opts
const esc = (s: string): string => const esc = (s: string): string =>
s s
.replace(/&/g, '&amp;') .replace(/&/g, '&amp;')
@@ -46,8 +65,8 @@ export function buildSitemapXml(entries: SitemapEntry[]): string {
e.lastModified instanceof Date ? e.lastModified.toISOString() : String(e.lastModified) e.lastModified instanceof Date ? e.lastModified.toISOString() : String(e.lastModified)
parts.push(` <lastmod>${esc(iso)}</lastmod>`) parts.push(` <lastmod>${esc(iso)}</lastmod>`)
} }
if (e.changeFrequency) {parts.push(` <changefreq>${e.changeFrequency}</changefreq>`)} if (e.changeFrequency) parts.push(` <changefreq>${e.changeFrequency}</changefreq>`)
if (typeof e.priority === 'number') {parts.push(` <priority>${e.priority}</priority>`)} if (typeof e.priority === 'number') parts.push(` <priority>${e.priority}</priority>`)
const alternates = e.alternates?.languages const alternates = e.alternates?.languages
if (alternates) { if (alternates) {
for (const [lang, href] of Object.entries(alternates)) { for (const [lang, href] of Object.entries(alternates)) {
@@ -62,8 +81,11 @@ export function buildSitemapXml(entries: SitemapEntry[]): string {
}) })
.join('\n') .join('\n')
const stylesheet = cssUrl ? `<?xml-stylesheet type="text/css" href="${esc(cssUrl)}"?>\n` : ''
return ( return (
`<?xml version="1.0" encoding="UTF-8"?>\n` + `<?xml version="1.0" encoding="UTF-8"?>\n` +
stylesheet +
`<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" ` + `<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" ` +
`xmlns:xhtml="http://www.w3.org/1999/xhtml">\n` + `xmlns:xhtml="http://www.w3.org/1999/xhtml">\n` +
urls + urls +