@djangocfg/nextjs 2.1.550 → 2.1.555
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -3
- package/dist/index.mjs +19 -4
- package/dist/index.mjs.map +1 -1
- package/dist/og-image/index.d.mts +16 -7
- package/dist/og-image/index.mjs.map +1 -1
- package/dist/sitemap/index.mjs +19 -4
- package/dist/sitemap/index.mjs.map +1 -1
- package/package.json +9 -9
- package/src/og-image/README.md +19 -4
- package/src/og-image/metadata-factory.ts +4 -2
- package/src/og-image/metadata.ts +6 -3
- package/src/og-image/types.ts +6 -2
- package/src/sitemap/fetch.ts +41 -4
|
@@ -8,8 +8,12 @@ import { Metadata } from 'next';
|
|
|
8
8
|
*
|
|
9
9
|
* There is deliberately no "render this title onto a card" case. That was
|
|
10
10
|
* served by a Django endpoint (`/cfg/og/<base64>/`), archived 2026-09-03 —
|
|
11
|
-
* Next.js renders such cards itself via an `opengraph-image` route
|
|
12
|
-
*
|
|
11
|
+
* Next.js renders such cards itself via an `opengraph-image` route.
|
|
12
|
+
*
|
|
13
|
+
* That route is not a drop-in, though: both spec cases set `openGraph.images`,
|
|
14
|
+
* and Next.js skips the file convention whenever metadata already does. A page
|
|
15
|
+
* wanting a rendered card must omit the key from its metadata (see
|
|
16
|
+
* `resolveOgImage`), not merely add the route file.
|
|
13
17
|
*/
|
|
14
18
|
type OgSpec = 'static' | {
|
|
15
19
|
image: string;
|
|
@@ -60,8 +64,10 @@ interface PageMeta {
|
|
|
60
64
|
* - `'static'` brand image from config (default)
|
|
61
65
|
* - `{ image, alt? }` an exact image URL (e.g. a content screenshot)
|
|
62
66
|
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
67
|
+
* Every case here writes `openGraph.images`, which SUPPRESSES an
|
|
68
|
+
* `opengraph-image.tsx` route for the page — Next.js honours that file
|
|
69
|
+
* convention only when metadata sets no images. A per-page rendered card
|
|
70
|
+
* therefore needs metadata that omits the key, not just the route file.
|
|
65
71
|
*/
|
|
66
72
|
og?: OgSpec;
|
|
67
73
|
/** Escape hatch: extra Metadata fields merged last (robots, authors, etc.). */
|
|
@@ -106,9 +112,12 @@ declare function extractTitle(metadata: Metadata): string;
|
|
|
106
112
|
* This is the single decision point for "what is the OG image" — no other code
|
|
107
113
|
* branches on the spec.
|
|
108
114
|
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
115
|
+
* An `opengraph-image` route does NOT override this. Next.js applies that file
|
|
116
|
+
* convention only when metadata leaves `openGraph.images` unset, and this
|
|
117
|
+
* function always sets it — so while `createMetadata` runs for a page, its
|
|
118
|
+
* `opengraph-image.tsx` is silently never rendered. To use a per-page card, the
|
|
119
|
+
* page must keep `openGraph.images` out of its metadata entirely (build it
|
|
120
|
+
* without this function, or delete the key afterwards).
|
|
112
121
|
*/
|
|
113
122
|
declare function resolveOgImage(spec: OgSpec, staticUrl: string, fallbackTitle: string): Pick<Metadata, 'openGraph' | 'twitter'>;
|
|
114
123
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/og-image/metadata.ts","../../src/og-image/metadata-factory.ts"],"sourcesContent":["import type { Metadata } from 'next'\nimport type { OgSpec } from './types'\n\nconst OG_WIDTH = 1200\nconst OG_HEIGHT = 630\n\n/** A Next.js OG image entry. */\nfunction imageEntry(url: string, alt: string) {\n return { url, width: OG_WIDTH, height: OG_HEIGHT, alt }\n}\n\n/**\n * Builds the og/twitter image fragment for a given image URL — the one place\n * that knows the shape Next.js expects.\n */\nfunction ogImageFragment(url: string, alt: string): Pick<Metadata, 'openGraph' | 'twitter'> {\n return {\n openGraph: { images: [imageEntry(url, alt)] },\n twitter: { card: 'summary_large_image', images: [url] },\n }\n}\n\n/** Extract a plain title string from Next.js's title shapes. */\nexport function extractTitle(metadata: Metadata): string {\n const t = metadata.title\n if (!t) return ''\n if (typeof t === 'string') return t\n if (typeof t === 'object' && 'absolute' in t) return (t as { absolute: string }).absolute\n if (typeof t === 'object' && 'default' in t) return (t as { default: string }).default\n return ''\n}\n\n/**\n * Resolves an `OgSpec` into a concrete og/twitter image fragment. `staticUrl`\n * is the site's brand image, used for the `'static'` case.\n *\n * This is the single decision point for \"what is the OG image\" — no other code\n * branches on the spec.\n *\n * To render a per-page card instead, add an `opengraph-image` route: Next.js\n * discovers it by filename and overrides whatever is set here, so it needs no\n * support from this function.\n */\nexport function resolveOgImage(\n spec: OgSpec,\n staticUrl: string,\n fallbackTitle: string\n): Pick<Metadata, 'openGraph' | 'twitter'> {\n if (spec === 'static') {\n return ogImageFragment(staticUrl, fallbackTitle)\n }\n return ogImageFragment(spec.image, spec.alt ?? fallbackTitle)\n}\n","import type { Metadata } from 'next'\nimport type { OgSpec } from './types'\nimport { resolveOgImage } from './metadata'\n\n/**\n * Site-wide identity the metadata factory needs. Config-agnostic on purpose —\n * the package doesn't know about any app's `settings` object, so the consumer\n * passes these once (typically derived from their own settings module).\n */\nexport interface SiteMetaConfig {\n /** Brand / app name, e.g. \"CMDOP\". Used in titles, siteName, alt text. */\n name: string\n /** Default description used when a page doesn't supply its own. */\n description: string\n /** Absolute site origin, e.g. \"https://cmdop.com\". Used for canonical/og url. */\n siteUrl: string\n /** Ready-made static brand OG image URL (absolute or root-relative). */\n ogImage: string\n /** Optional favicon / apple-touch icons forwarded to Metadata.icons. */\n icons?: Metadata['icons']\n /** Locales for hreflang alternates. Omit for single-locale sites. */\n locales?: readonly string[]\n /** Locale used for x-default hreflang (defaults to \"en\" or first locale). */\n defaultLocale?: string\n}\n\n/**\n * Per-page inputs. Only `title` is really worth setting per page; everything\n * else falls back to the site config.\n */\nexport interface PageMeta {\n /** Page title (without the brand suffix). Falls back to the site name. */\n title?: string\n /** Page description; falls back to the site description. */\n description?: string\n keywords?: string[]\n /** Current locale, used for canonical + openGraph.locale + alternates. */\n locale?: string\n /**\n * Path without locale prefix, e.g. \"/skills\" or \"/skills/foo\". Home is ''\n * (or '/'). Leading/trailing slashes are normalized, so both canonical and\n * og:url come out well-formed whichever you pass.\n */\n path?: string\n /**\n * What the OG image should be. Defaults to `'static'` (the brand image).\n * - `'static'` brand image from config (default)\n * - `{ image, alt? }` an exact image URL (e.g. a content screenshot)\n *\n * For a per-page rendered card, add an `opengraph-image` route instead —\n * Next.js finds it by filename and it overrides whatever is set here.\n */\n og?: OgSpec\n /** Escape hatch: extra Metadata fields merged last (robots, authors, etc.). */\n extra?: Metadata\n}\n\n/**\n * Joins the site origin, an optional locale segment, and a page path into one\n * absolute URL.\n *\n * This is the SINGLE place the URL shape is decided. `canonical` and\n * `openGraph.url` describe the same page, so they must be built by the same\n * function — when they were two hand-rolled template literals they drifted\n * apart (one inserted a slash the other didn't), and a page shipped a canonical\n * and an og:url pointing at two different URLs.\n *\n * Contract (see `PageMeta.path`): `path` carries its own leading slash —\n * \"/skills\", \"/skills/foo\" — and home is '' (we also tolerate '/'). Segments\n * are normalized rather than trusted, so a stray or missing slash at a call\n * site cannot produce \"site.com//skills\" or \"site.comskills\".\n */\nfunction buildUrl(siteUrl: string, locale: string | undefined, path: string): string {\n const origin = siteUrl.replace(/\\/+$/, '')\n const segments = [locale, path]\n .map((segment) => segment?.replace(/^\\/+|\\/+$/g, '') ?? '')\n .filter(Boolean)\n return segments.length ? `${origin}/${segments.join('/')}` : origin\n}\n\nfunction buildAlternates(\n config: SiteMetaConfig,\n path: string,\n locale: string\n): Metadata['alternates'] {\n if (!config.locales?.length) {\n return { canonical: buildUrl(config.siteUrl, undefined, path) }\n }\n const languages: Record<string, string> = {}\n for (const loc of config.locales) {\n languages[loc] = buildUrl(config.siteUrl, loc, path)\n }\n const xDefault =\n config.defaultLocale ?? (config.locales.includes('en') ? 'en' : config.locales[0])\n languages['x-default'] = buildUrl(config.siteUrl, xDefault, path)\n return { canonical: buildUrl(config.siteUrl, locale, path), languages }\n}\n\n/**\n * Builds a complete Next.js `Metadata` from a one-time site config plus per-page\n * inputs — title-template, description, openGraph, twitter, canonical + hreflang,\n * icons, and the OG image — so pages only declare what's unique.\n *\n * The OG image is chosen by the single `og` field (defaults to the static brand\n * image). There is exactly one way to do each thing.\n *\n * // app/_core/metadata.ts\n * export const createMetadata = makeMetadataFactory({\n * name: settings.app.name,\n * description: settings.app.description,\n * siteUrl: settings.app.siteUrl,\n * ogImage: settings.app.media.ogimage,\n * icons: { icon: settings.app.media.favicon },\n * locales: LOCALES,\n * })\n *\n * // landing page — static brand image (default)\n * export const metadata = createMetadata({ title: 'Pricing', path: '/pricing' })\n * // content page with its own image\n * export const metadata = createMetadata({ title: p.name, og: { image: p.image } })\n */\nexport function createMetadata(config: SiteMetaConfig, page: PageMeta = {}): Metadata {\n const title = page.title ?? config.name\n const description = page.description ?? config.description\n const locale = page.locale ?? config.defaultLocale ?? 'en'\n const path = page.path ?? ''\n // Same builder as the canonical below — og:url and canonical name the same\n // page, so they are never allowed to be assembled by two different rules.\n const ogUrl = buildUrl(config.siteUrl, config.locales?.length ? locale : undefined, path)\n\n const ogImage = resolveOgImage(page.og ?? 'static', config.ogImage, title)\n\n const metadata: Metadata = {\n title,\n description,\n keywords: page.keywords,\n alternates: buildAlternates(config, path, locale),\n openGraph: {\n type: 'website',\n locale,\n url: ogUrl,\n siteName: config.name,\n title,\n description,\n ...ogImage.openGraph,\n },\n twitter: {\n title,\n description,\n ...ogImage.twitter,\n },\n icons: config.icons,\n }\n\n if (!page.extra) return metadata\n // Shallow-merge the escape hatch, but deep-merge the nested og/twitter so the\n // image we just set isn't clobbered by a partial `extra.openGraph`.\n return {\n ...metadata,\n ...page.extra,\n openGraph: { ...metadata.openGraph, ...page.extra.openGraph },\n twitter: { ...metadata.twitter, ...page.extra.twitter },\n }\n}\n\n/**\n * Curries `createMetadata` with a fixed site config so pages call a zero-config\n * factory. This is the single recommended entry point for app metadata.\n */\nexport function makeMetadataFactory(config: SiteMetaConfig) {\n return (page: PageMeta = {}): Metadata => createMetadata(config, page)\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/og-image/metadata.ts","../../src/og-image/metadata-factory.ts"],"sourcesContent":["import type { Metadata } from 'next'\nimport type { OgSpec } from './types'\n\nconst OG_WIDTH = 1200\nconst OG_HEIGHT = 630\n\n/** A Next.js OG image entry. */\nfunction imageEntry(url: string, alt: string) {\n return { url, width: OG_WIDTH, height: OG_HEIGHT, alt }\n}\n\n/**\n * Builds the og/twitter image fragment for a given image URL — the one place\n * that knows the shape Next.js expects.\n */\nfunction ogImageFragment(url: string, alt: string): Pick<Metadata, 'openGraph' | 'twitter'> {\n return {\n openGraph: { images: [imageEntry(url, alt)] },\n twitter: { card: 'summary_large_image', images: [url] },\n }\n}\n\n/** Extract a plain title string from Next.js's title shapes. */\nexport function extractTitle(metadata: Metadata): string {\n const t = metadata.title\n if (!t) return ''\n if (typeof t === 'string') return t\n if (typeof t === 'object' && 'absolute' in t) return (t as { absolute: string }).absolute\n if (typeof t === 'object' && 'default' in t) return (t as { default: string }).default\n return ''\n}\n\n/**\n * Resolves an `OgSpec` into a concrete og/twitter image fragment. `staticUrl`\n * is the site's brand image, used for the `'static'` case.\n *\n * This is the single decision point for \"what is the OG image\" — no other code\n * branches on the spec.\n *\n * An `opengraph-image` route does NOT override this. Next.js applies that file\n * convention only when metadata leaves `openGraph.images` unset, and this\n * function always sets it — so while `createMetadata` runs for a page, its\n * `opengraph-image.tsx` is silently never rendered. To use a per-page card, the\n * page must keep `openGraph.images` out of its metadata entirely (build it\n * without this function, or delete the key afterwards).\n */\nexport function resolveOgImage(\n spec: OgSpec,\n staticUrl: string,\n fallbackTitle: string\n): Pick<Metadata, 'openGraph' | 'twitter'> {\n if (spec === 'static') {\n return ogImageFragment(staticUrl, fallbackTitle)\n }\n return ogImageFragment(spec.image, spec.alt ?? fallbackTitle)\n}\n","import type { Metadata } from 'next'\nimport type { OgSpec } from './types'\nimport { resolveOgImage } from './metadata'\n\n/**\n * Site-wide identity the metadata factory needs. Config-agnostic on purpose —\n * the package doesn't know about any app's `settings` object, so the consumer\n * passes these once (typically derived from their own settings module).\n */\nexport interface SiteMetaConfig {\n /** Brand / app name, e.g. \"CMDOP\". Used in titles, siteName, alt text. */\n name: string\n /** Default description used when a page doesn't supply its own. */\n description: string\n /** Absolute site origin, e.g. \"https://cmdop.com\". Used for canonical/og url. */\n siteUrl: string\n /** Ready-made static brand OG image URL (absolute or root-relative). */\n ogImage: string\n /** Optional favicon / apple-touch icons forwarded to Metadata.icons. */\n icons?: Metadata['icons']\n /** Locales for hreflang alternates. Omit for single-locale sites. */\n locales?: readonly string[]\n /** Locale used for x-default hreflang (defaults to \"en\" or first locale). */\n defaultLocale?: string\n}\n\n/**\n * Per-page inputs. Only `title` is really worth setting per page; everything\n * else falls back to the site config.\n */\nexport interface PageMeta {\n /** Page title (without the brand suffix). Falls back to the site name. */\n title?: string\n /** Page description; falls back to the site description. */\n description?: string\n keywords?: string[]\n /** Current locale, used for canonical + openGraph.locale + alternates. */\n locale?: string\n /**\n * Path without locale prefix, e.g. \"/skills\" or \"/skills/foo\". Home is ''\n * (or '/'). Leading/trailing slashes are normalized, so both canonical and\n * og:url come out well-formed whichever you pass.\n */\n path?: string\n /**\n * What the OG image should be. Defaults to `'static'` (the brand image).\n * - `'static'` brand image from config (default)\n * - `{ image, alt? }` an exact image URL (e.g. a content screenshot)\n *\n * Every case here writes `openGraph.images`, which SUPPRESSES an\n * `opengraph-image.tsx` route for the page — Next.js honours that file\n * convention only when metadata sets no images. A per-page rendered card\n * therefore needs metadata that omits the key, not just the route file.\n */\n og?: OgSpec\n /** Escape hatch: extra Metadata fields merged last (robots, authors, etc.). */\n extra?: Metadata\n}\n\n/**\n * Joins the site origin, an optional locale segment, and a page path into one\n * absolute URL.\n *\n * This is the SINGLE place the URL shape is decided. `canonical` and\n * `openGraph.url` describe the same page, so they must be built by the same\n * function — when they were two hand-rolled template literals they drifted\n * apart (one inserted a slash the other didn't), and a page shipped a canonical\n * and an og:url pointing at two different URLs.\n *\n * Contract (see `PageMeta.path`): `path` carries its own leading slash —\n * \"/skills\", \"/skills/foo\" — and home is '' (we also tolerate '/'). Segments\n * are normalized rather than trusted, so a stray or missing slash at a call\n * site cannot produce \"site.com//skills\" or \"site.comskills\".\n */\nfunction buildUrl(siteUrl: string, locale: string | undefined, path: string): string {\n const origin = siteUrl.replace(/\\/+$/, '')\n const segments = [locale, path]\n .map((segment) => segment?.replace(/^\\/+|\\/+$/g, '') ?? '')\n .filter(Boolean)\n return segments.length ? `${origin}/${segments.join('/')}` : origin\n}\n\nfunction buildAlternates(\n config: SiteMetaConfig,\n path: string,\n locale: string\n): Metadata['alternates'] {\n if (!config.locales?.length) {\n return { canonical: buildUrl(config.siteUrl, undefined, path) }\n }\n const languages: Record<string, string> = {}\n for (const loc of config.locales) {\n languages[loc] = buildUrl(config.siteUrl, loc, path)\n }\n const xDefault =\n config.defaultLocale ?? (config.locales.includes('en') ? 'en' : config.locales[0])\n languages['x-default'] = buildUrl(config.siteUrl, xDefault, path)\n return { canonical: buildUrl(config.siteUrl, locale, path), languages }\n}\n\n/**\n * Builds a complete Next.js `Metadata` from a one-time site config plus per-page\n * inputs — title-template, description, openGraph, twitter, canonical + hreflang,\n * icons, and the OG image — so pages only declare what's unique.\n *\n * The OG image is chosen by the single `og` field (defaults to the static brand\n * image). There is exactly one way to do each thing.\n *\n * // app/_core/metadata.ts\n * export const createMetadata = makeMetadataFactory({\n * name: settings.app.name,\n * description: settings.app.description,\n * siteUrl: settings.app.siteUrl,\n * ogImage: settings.app.media.ogimage,\n * icons: { icon: settings.app.media.favicon },\n * locales: LOCALES,\n * })\n *\n * // landing page — static brand image (default)\n * export const metadata = createMetadata({ title: 'Pricing', path: '/pricing' })\n * // content page with its own image\n * export const metadata = createMetadata({ title: p.name, og: { image: p.image } })\n */\nexport function createMetadata(config: SiteMetaConfig, page: PageMeta = {}): Metadata {\n const title = page.title ?? config.name\n const description = page.description ?? config.description\n const locale = page.locale ?? config.defaultLocale ?? 'en'\n const path = page.path ?? ''\n // Same builder as the canonical below — og:url and canonical name the same\n // page, so they are never allowed to be assembled by two different rules.\n const ogUrl = buildUrl(config.siteUrl, config.locales?.length ? locale : undefined, path)\n\n const ogImage = resolveOgImage(page.og ?? 'static', config.ogImage, title)\n\n const metadata: Metadata = {\n title,\n description,\n keywords: page.keywords,\n alternates: buildAlternates(config, path, locale),\n openGraph: {\n type: 'website',\n locale,\n url: ogUrl,\n siteName: config.name,\n title,\n description,\n ...ogImage.openGraph,\n },\n twitter: {\n title,\n description,\n ...ogImage.twitter,\n },\n icons: config.icons,\n }\n\n if (!page.extra) return metadata\n // Shallow-merge the escape hatch, but deep-merge the nested og/twitter so the\n // image we just set isn't clobbered by a partial `extra.openGraph`.\n return {\n ...metadata,\n ...page.extra,\n openGraph: { ...metadata.openGraph, ...page.extra.openGraph },\n twitter: { ...metadata.twitter, ...page.extra.twitter },\n }\n}\n\n/**\n * Curries `createMetadata` with a fixed site config so pages call a zero-config\n * factory. This is the single recommended entry point for app metadata.\n */\nexport function makeMetadataFactory(config: SiteMetaConfig) {\n return (page: PageMeta = {}): Metadata => createMetadata(config, page)\n}\n"],"mappings":";AAGA,IAAM,WAAW;AACjB,IAAM,YAAY;AAGlB,SAAS,WAAW,KAAa,KAAa;AAC5C,SAAO,EAAE,KAAK,OAAO,UAAU,QAAQ,WAAW,IAAI;AACxD;AAMA,SAAS,gBAAgB,KAAa,KAAsD;AAC1F,SAAO;AAAA,IACL,WAAW,EAAE,QAAQ,CAAC,WAAW,KAAK,GAAG,CAAC,EAAE;AAAA,IAC5C,SAAS,EAAE,MAAM,uBAAuB,QAAQ,CAAC,GAAG,EAAE;AAAA,EACxD;AACF;AAGO,SAAS,aAAa,UAA4B;AACvD,QAAM,IAAI,SAAS;AACnB,MAAI,CAAC,EAAG,QAAO;AACf,MAAI,OAAO,MAAM,SAAU,QAAO;AAClC,MAAI,OAAO,MAAM,YAAY,cAAc,EAAG,QAAQ,EAA2B;AACjF,MAAI,OAAO,MAAM,YAAY,aAAa,EAAG,QAAQ,EAA0B;AAC/E,SAAO;AACT;AAgBO,SAAS,eACd,MACA,WACA,eACyC;AACzC,MAAI,SAAS,UAAU;AACrB,WAAO,gBAAgB,WAAW,aAAa;AAAA,EACjD;AACA,SAAO,gBAAgB,KAAK,OAAO,KAAK,OAAO,aAAa;AAC9D;;;ACmBA,SAAS,SAAS,SAAiB,QAA4B,MAAsB;AACnF,QAAM,SAAS,QAAQ,QAAQ,QAAQ,EAAE;AACzC,QAAM,WAAW,CAAC,QAAQ,IAAI,EAC3B,IAAI,CAAC,YAAY,SAAS,QAAQ,cAAc,EAAE,KAAK,EAAE,EACzD,OAAO,OAAO;AACjB,SAAO,SAAS,SAAS,GAAG,MAAM,IAAI,SAAS,KAAK,GAAG,CAAC,KAAK;AAC/D;AAEA,SAAS,gBACP,QACA,MACA,QACwB;AACxB,MAAI,CAAC,OAAO,SAAS,QAAQ;AAC3B,WAAO,EAAE,WAAW,SAAS,OAAO,SAAS,QAAW,IAAI,EAAE;AAAA,EAChE;AACA,QAAM,YAAoC,CAAC;AAC3C,aAAW,OAAO,OAAO,SAAS;AAChC,cAAU,GAAG,IAAI,SAAS,OAAO,SAAS,KAAK,IAAI;AAAA,EACrD;AACA,QAAM,WACJ,OAAO,kBAAkB,OAAO,QAAQ,SAAS,IAAI,IAAI,OAAO,OAAO,QAAQ,CAAC;AAClF,YAAU,WAAW,IAAI,SAAS,OAAO,SAAS,UAAU,IAAI;AAChE,SAAO,EAAE,WAAW,SAAS,OAAO,SAAS,QAAQ,IAAI,GAAG,UAAU;AACxE;AAyBO,SAAS,eAAe,QAAwB,OAAiB,CAAC,GAAa;AACpF,QAAM,QAAQ,KAAK,SAAS,OAAO;AACnC,QAAM,cAAc,KAAK,eAAe,OAAO;AAC/C,QAAM,SAAS,KAAK,UAAU,OAAO,iBAAiB;AACtD,QAAM,OAAO,KAAK,QAAQ;AAG1B,QAAM,QAAQ,SAAS,OAAO,SAAS,OAAO,SAAS,SAAS,SAAS,QAAW,IAAI;AAExF,QAAM,UAAU,eAAe,KAAK,MAAM,UAAU,OAAO,SAAS,KAAK;AAEzE,QAAM,WAAqB;AAAA,IACzB;AAAA,IACA;AAAA,IACA,UAAU,KAAK;AAAA,IACf,YAAY,gBAAgB,QAAQ,MAAM,MAAM;AAAA,IAChD,WAAW;AAAA,MACT,MAAM;AAAA,MACN;AAAA,MACA,KAAK;AAAA,MACL,UAAU,OAAO;AAAA,MACjB;AAAA,MACA;AAAA,MACA,GAAG,QAAQ;AAAA,IACb;AAAA,IACA,SAAS;AAAA,MACP;AAAA,MACA;AAAA,MACA,GAAG,QAAQ;AAAA,IACb;AAAA,IACA,OAAO,OAAO;AAAA,EAChB;AAEA,MAAI,CAAC,KAAK,MAAO,QAAO;AAGxB,SAAO;AAAA,IACL,GAAG;AAAA,IACH,GAAG,KAAK;AAAA,IACR,WAAW,EAAE,GAAG,SAAS,WAAW,GAAG,KAAK,MAAM,UAAU;AAAA,IAC5D,SAAS,EAAE,GAAG,SAAS,SAAS,GAAG,KAAK,MAAM,QAAQ;AAAA,EACxD;AACF;AAMO,SAAS,oBAAoB,QAAwB;AAC1D,SAAO,CAAC,OAAiB,CAAC,MAAgB,eAAe,QAAQ,IAAI;AACvE;","names":[]}
|
package/dist/sitemap/index.mjs
CHANGED
|
@@ -4,6 +4,19 @@ var DEFAULT_INDEX_REVALIDATE = 600;
|
|
|
4
4
|
var DEFAULT_FEED_REVALIDATE = 3600;
|
|
5
5
|
|
|
6
6
|
// src/sitemap/fetch.ts
|
|
7
|
+
var NEXT_DATA_CACHE_LIMIT = 2 * 1024 * 1024;
|
|
8
|
+
var oversizeWarned = /* @__PURE__ */ new Set();
|
|
9
|
+
async function parseAndCheckSize(r, url, label) {
|
|
10
|
+
const text = await r.text();
|
|
11
|
+
const bytes = new TextEncoder().encode(text).length;
|
|
12
|
+
if (bytes > NEXT_DATA_CACHE_LIMIT && !oversizeWarned.has(url)) {
|
|
13
|
+
oversizeWarned.add(url);
|
|
14
|
+
console.warn(
|
|
15
|
+
`[sitemap] ${label} is ${bytes} bytes, over Next.js's 2MB data-cache ceiling: revalidate is inert for this feed and every regeneration refetches it from Django. Lower the backend page_size if the extra load matters \u2014 note 50,000 URLs is the sitemap protocol's own maximum, so a large payload here is not itself a bug.`
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
return JSON.parse(text);
|
|
19
|
+
}
|
|
7
20
|
var EMPTY_INDEX = {
|
|
8
21
|
sources: [],
|
|
9
22
|
generated_at: (/* @__PURE__ */ new Date(0)).toISOString(),
|
|
@@ -18,15 +31,16 @@ var emptyFeed = (source, cursor) => ({
|
|
|
18
31
|
entries: []
|
|
19
32
|
});
|
|
20
33
|
async function fetchSitemapIndex(apiUrl, revalidate) {
|
|
34
|
+
const url = `${apiUrl}/cfg/sitemap/index/`;
|
|
21
35
|
try {
|
|
22
|
-
const r = await fetch(
|
|
36
|
+
const r = await fetch(url, {
|
|
23
37
|
next: { revalidate }
|
|
24
38
|
});
|
|
25
39
|
if (!r.ok) {
|
|
26
40
|
console.warn(`[sitemap] index fetch returned ${r.status}; falling back to empty`);
|
|
27
41
|
return EMPTY_INDEX;
|
|
28
42
|
}
|
|
29
|
-
return await r
|
|
43
|
+
return await parseAndCheckSize(r, url, "index");
|
|
30
44
|
} catch (err) {
|
|
31
45
|
console.warn("[sitemap] index fetch failed:", err);
|
|
32
46
|
return EMPTY_INDEX;
|
|
@@ -35,15 +49,16 @@ async function fetchSitemapIndex(apiUrl, revalidate) {
|
|
|
35
49
|
async function fetchSitemapFeed(apiUrl, source, cursor, revalidate) {
|
|
36
50
|
const params = new URLSearchParams({ source });
|
|
37
51
|
if (cursor) params.set("cursor", cursor);
|
|
52
|
+
const url = `${apiUrl}/cfg/sitemap/feed/?${params}`;
|
|
38
53
|
try {
|
|
39
|
-
const r = await fetch(
|
|
54
|
+
const r = await fetch(url, {
|
|
40
55
|
next: { revalidate }
|
|
41
56
|
});
|
|
42
57
|
if (!r.ok) {
|
|
43
58
|
console.warn(`[sitemap] feed ${source} returned ${r.status}; falling back to empty`);
|
|
44
59
|
return emptyFeed(source, cursor);
|
|
45
60
|
}
|
|
46
|
-
return await r
|
|
61
|
+
return await parseAndCheckSize(r, url, `feed ${source}`);
|
|
47
62
|
} catch (err) {
|
|
48
63
|
console.warn(`[sitemap] feed ${source} fetch failed:`, err);
|
|
49
64
|
return emptyFeed(source, cursor);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/sitemap/constants.ts","../../src/sitemap/fetch.ts","../../src/sitemap/ids.ts","../../src/sitemap/select.ts","../../src/sitemap/sitemap.ts","../../src/sitemap/index-route.ts","../../src/sitemap/robots.ts"],"sourcesContent":["/**\n * Shared sitemap constants.\n *\n * Pulled out so both the chunk factory (`createDjangoSitemap`) and the\n * index Route Handler (`createSitemapIndex`) agree on the id of the\n * frontend-only static-routes chunk.\n */\n\nexport const STATIC_ID = 'static';\nexport const DEFAULT_INDEX_REVALIDATE = 600;\nexport const DEFAULT_FEED_REVALIDATE = 3600;\n","/**\n * Sitemap fetch helpers — never throw.\n *\n * Failures are converted to empty payloads so a transient Django outage\n * doesn't break `next build`. The next ISR cycle (`revalidate`) recovers\n * automatically.\n */\n\nimport type { SitemapFeedPage, SitemapIndex } from './types';\n\nconst EMPTY_INDEX: SitemapIndex = {\n sources: [],\n generated_at: new Date(0).toISOString(),\n ttl_seconds: 60,\n};\n\nconst emptyFeed = (source: string, cursor: string | null): SitemapFeedPage => ({\n source,\n chunk_id: `${source}-empty`,\n count: 0,\n has_more: false,\n next_cursor: cursor,\n entries: [],\n});\n\nexport async function fetchSitemapIndex(\n apiUrl: string,\n revalidate: number,\n): Promise<SitemapIndex> {\n try {\n const r = await fetch(`${apiUrl}/cfg/sitemap/index/`, {\n next: { revalidate },\n });\n if (!r.ok) {\n console.warn(`[sitemap] index fetch returned ${r.status}; falling back to empty`);\n return EMPTY_INDEX;\n }\n return (await r.json()) as SitemapIndex;\n } catch (err) {\n console.warn('[sitemap] index fetch failed:', err);\n return EMPTY_INDEX;\n }\n}\n\nexport async function fetchSitemapFeed(\n apiUrl: string,\n source: string,\n cursor: string | null,\n revalidate: number,\n): Promise<SitemapFeedPage> {\n const params = new URLSearchParams({ source });\n if (cursor) params.set('cursor', cursor);\n try {\n const r = await fetch(`${apiUrl}/cfg/sitemap/feed/?${params}`, {\n next: { revalidate },\n });\n if (!r.ok) {\n console.warn(`[sitemap] feed ${source} returned ${r.status}; falling back to empty`);\n return emptyFeed(source, cursor);\n }\n return (await r.json()) as SitemapFeedPage;\n } catch (err) {\n console.warn(`[sitemap] feed ${source} fetch failed:`, err);\n return emptyFeed(source, cursor);\n }\n}\n","/**\n * Sitemap chunk id encoding.\n *\n * Each Next.js sitemap file is addressed by an opaque string id. We\n * round-trip a `(source, cursor)` pair through that id so the default\n * sitemap handler knows which Django chunk to fetch.\n *\n * Separator `--` is unambiguous: backend cursors use URL-safe base64 (`-_`).\n */\n\nexport function encodeChunkId(source: string, cursor: string | null): string {\n return `${source}--${cursor ?? ''}`;\n}\n\nexport function decodeChunkId(id: string): { source: string; cursor: string | null } {\n const sep = id.indexOf('--');\n if (sep === -1) return { source: id, cursor: null };\n const source = id.slice(0, sep);\n const cursorRaw = id.slice(sep + 2);\n return { source, cursor: cursorRaw === '' ? null : cursorRaw };\n}\n","/**\n * Which backend sources a frontend claims.\n *\n * `GET /cfg/sitemap/index/` answers for the whole Django project — every\n * source any installed app registered. One frontend is not always entitled\n * to all of them: a backend serving several brands registers a source per\n * brand (`properties-caribbean`, `properties-bali`), and each brand's app\n * must emit only its own, or it advertises the other's URLs under its own\n * host — every one of them a 404, and the sitemap that carries them is\n * discredited whole.\n *\n * The selector lives here, shared, for the same reason `STATIC_ID` does:\n * `createDjangoSitemap` enumerates the chunks and `createSitemapIndex`\n * enumerates them again for the index document. If the two applied\n * different filters, the index would advertise shards the chunk route\n * refuses to build — and that mismatch is invisible until a crawler reads\n * both. One function, called by both, cannot drift.\n *\n * Source NAMES are the contract. The module ships no notion of a brand or\n * a tenant, and it should not: the backend chose what its source names\n * mean, and only the app knows which of them are its own.\n */\n\nimport type { SitemapIndex, SitemapSourceInfo, SourceSelector } from './types';\n\n/**\n * A predicate from whatever the caller passed.\n *\n * Omitted means every source — the single-tenant default, and what every\n * caller before this option existed already relied on.\n */\nexport function sourceFilter(select: SourceSelector | undefined): (name: string) => boolean {\n if (select === undefined) return () => true;\n if (typeof select === 'function') return select;\n const allowed = new Set(select);\n return (name) => allowed.has(name);\n}\n\n/** The index's sources, narrowed to those the caller claims. */\nexport function selectSources(\n index: SitemapIndex,\n select: SourceSelector | undefined,\n): SitemapSourceInfo[] {\n const keep = sourceFilter(select);\n return index.sources.filter((source) => keep(source.name));\n}\n\n/**\n * Every chunk id the selected sources produce, in index order.\n *\n * The one place `(source, chunk)` becomes an id, so the chunk route and the\n * index document enumerate the same set by construction.\n */\nexport function selectedChunkIds(\n index: SitemapIndex,\n select: SourceSelector | undefined,\n encode: (source: string, cursor: string | null) => string,\n): string[] {\n const ids: string[] = [];\n for (const source of selectSources(index, select)) {\n for (const chunk of source.chunks) {\n ids.push(encode(source.name, chunk.cursor_to));\n }\n }\n return ids;\n}\n\n/**\n * A selector for one tenant of a multi-brand backend.\n *\n * Matches the `<source>-<tenant>` names that a backend registering per\n * tenant emits. It splits on the LAST hyphen and compares the tail exactly,\n * rather than testing a suffix: `endsWith('-bali')` is correct for\n * `properties-bali` but the same shape reads `pricing-plans-premium` as\n * belonging to a `premium` tenant, and a substring match is not a contract.\n *\n * ```ts\n * sources: tenantSources('bali') // properties-bali, communities-bali, …\n * ```\n *\n * Sources with no hyphen never match: an untenanted source belongs to the\n * project rather than to a brand, and claiming it for one is the error this\n * function exists to prevent.\n */\nexport function tenantSources(tenant: string): (name: string) => boolean {\n return (name) => {\n const sep = name.lastIndexOf('-');\n return sep !== -1 && name.slice(sep + 1) === tenant;\n };\n}\n","/**\n * createDjangoSitemap — factory for Next.js `app/sitemap.ts`.\n *\n * Backed by `django_cfg.modules.django_sitemap`: the backend exposes a\n * paginated JSON feed, this factory turns each chunk into a Next.js\n * sitemap file via `generateSitemaps()`.\n *\n * Usage:\n *\n * ```ts\n * // apps/<app>/app/sitemap.ts\n * import { createDjangoSitemap } from '@djangocfg/nextjs/sitemap';\n *\n * const { generateSitemaps, sitemap } = createDjangoSitemap({\n * host: process.env.NEXT_PUBLIC_SITE_URL!,\n * apiUrl: process.env.NEXT_PUBLIC_API_URL!,\n * staticRoutes: [\n * { path: '/', changeFrequency: 'daily', priority: 1.0 },\n * { path: '/catalog', changeFrequency: 'daily', priority: 0.9 },\n * ],\n * });\n *\n * export { generateSitemaps, sitemap as default };\n * export const revalidate = 3600;\n * ```\n *\n * Without `apiUrl`, the sitemap emits only the static routes — handy for\n * marketing sites that don't have a backend-driven URL space.\n */\n\nimport type { MetadataRoute } from 'next';\n\nimport {\n DEFAULT_FEED_REVALIDATE,\n DEFAULT_INDEX_REVALIDATE,\n STATIC_ID,\n} from './constants';\nimport { fetchSitemapFeed, fetchSitemapIndex } from './fetch';\nimport { decodeChunkId, encodeChunkId } from './ids';\nimport { selectedChunkIds, sourceFilter } from './select';\n\nimport type { CreateDjangoSitemapOptions, StaticRoute } from './types';\n\ninterface SitemapApi {\n generateSitemaps: () => Promise<Array<{ id: string }>>;\n sitemap: (props: { id: Promise<string> }) => Promise<MetadataRoute.Sitemap>;\n}\n\nexport function createDjangoSitemap(opts: CreateDjangoSitemapOptions): SitemapApi {\n const {\n host,\n apiUrl,\n staticRoutes = [],\n sources,\n indexRevalidate = DEFAULT_INDEX_REVALIDATE,\n feedRevalidate = DEFAULT_FEED_REVALIDATE,\n } = opts;\n\n return {\n async generateSitemaps() {\n const ids: Array<{ id: string }> = [{ id: STATIC_ID }];\n if (!apiUrl) return ids;\n const index = await fetchSitemapIndex(apiUrl, indexRevalidate);\n for (const id of selectedChunkIds(index, sources, encodeChunkId)) {\n ids.push({ id });\n }\n return ids;\n },\n\n async sitemap({ id: idPromise }) {\n const id = await idPromise;\n\n if (id === STATIC_ID) {\n return renderStatic(host, staticRoutes);\n }\n\n if (!apiUrl) return [];\n\n const { source, cursor } = decodeChunkId(id);\n // A chunk this app does not claim renders empty rather than fetching.\n // `generateSitemaps` never mints such an id, but the route stays\n // reachable by hand, and serving another tenant's URLs from a guessed\n // address would undo the filter above.\n if (!sourceFilter(sources)(source)) return [];\n\n const feed = await fetchSitemapFeed(apiUrl, source, cursor, feedRevalidate);\n return feed.entries.map((e) => ({\n url: `${host}${e.loc}`,\n lastModified: e.lastmod ? new Date(e.lastmod) : undefined,\n }));\n },\n };\n}\n\nfunction renderStatic(host: string, routes: StaticRoute[]): MetadataRoute.Sitemap {\n return routes.map((r) => ({\n url: `${host}${r.path}`,\n changeFrequency: r.changeFrequency,\n priority: r.priority,\n }));\n}\n","/**\n * createSitemapIndex — Route Handler factory for the sitemap-index XML.\n *\n * Next.js's `generateSitemaps()` API ships chunks at `/sitemap/<id>.xml`\n * but does NOT emit a single sitemap-index document. Without one,\n * robots.txt + crawlers point at a 404 and never discover the chunks.\n * This factory closes that gap: it returns a `GET` Route Handler that\n * mints a `<sitemapindex>` XML listing every chunk the matching\n * `createDjangoSitemap` would produce.\n *\n * Mount it at **`app/sitemap_index.xml/route.ts`** — NOT `sitemap.xml`.\n * `app/sitemap.ts` (where `createDjangoSitemap` lives) already owns\n * `/sitemap.xml` in Next.js's metadata routing, even when its output is\n * fan-out via `generateSitemaps()` and the singular URL serves 404.\n * Putting a route handler at `app/sitemap.xml/route.ts` throws at build\n * time: *Conflicting route and metadata at /sitemap.xml*.\n *\n * Usage — the full pattern with the matching chunk + robots factories:\n *\n * ```ts\n * // apps/<app>/app/sitemap.ts — chunks\n * import { createDjangoSitemap } from '@djangocfg/nextjs/sitemap';\n * const { generateSitemaps, sitemap } = createDjangoSitemap({ ... });\n * export { generateSitemaps, sitemap as default };\n *\n * // apps/<app>/app/sitemap_index.xml/route.ts — the index\n * import { createSitemapIndex } from '@djangocfg/nextjs/sitemap';\n * export const { GET } = createSitemapIndex({\n * host: process.env.NEXT_PUBLIC_SITE_URL!,\n * apiUrl: process.env.NEXT_PUBLIC_API_URL!,\n * });\n * export const revalidate = 600;\n *\n * // apps/<app>/app/robots.ts — point crawlers at the index URL above\n * import { createRobots } from '@djangocfg/nextjs/sitemap';\n * const host = process.env.NEXT_PUBLIC_SITE_URL!;\n * export default createRobots({\n * host,\n * sitemap: `${host}/sitemap_index.xml`,\n * });\n * ```\n *\n * Keep `host` / `apiUrl` in sync with the same app's `app/sitemap.ts`\n * — the index just enumerates the chunks the other file emits.\n */\n\nimport {\n DEFAULT_INDEX_REVALIDATE,\n STATIC_ID,\n} from './constants';\nimport { fetchSitemapIndex } from './fetch';\nimport { encodeChunkId } from './ids';\nimport { selectedChunkIds } from './select';\n\nimport type { CreateSitemapIndexOptions } from './types';\n\ninterface SitemapIndexRoute {\n GET: () => Promise<Response>;\n}\n\nexport function createSitemapIndex(opts: CreateSitemapIndexOptions): SitemapIndexRoute {\n const {\n host,\n apiUrl,\n sources,\n indexRevalidate = DEFAULT_INDEX_REVALIDATE,\n includeStatic = true,\n } = opts;\n\n return {\n async GET() {\n const ids: string[] = includeStatic ? [STATIC_ID] : [];\n\n if (apiUrl) {\n const index = await fetchSitemapIndex(apiUrl, indexRevalidate);\n ids.push(...selectedChunkIds(index, sources, encodeChunkId));\n }\n\n const lastmod = new Date().toISOString();\n const body = renderIndex(host, ids, lastmod);\n\n return new Response(body, {\n status: 200,\n headers: {\n 'content-type': 'application/xml; charset=utf-8',\n // Mirror the per-chunk revalidate so a cached CDN edge expires\n // at the same cadence as the index it points at.\n 'cache-control': `public, s-maxage=${indexRevalidate}, stale-while-revalidate=${indexRevalidate}`,\n },\n });\n },\n };\n}\n\nfunction renderIndex(host: string, ids: string[], lastmod: string): string {\n const lines: string[] = [];\n lines.push('<?xml version=\"1.0\" encoding=\"UTF-8\"?>');\n lines.push('<sitemapindex xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">');\n for (const id of ids) {\n lines.push(' <sitemap>');\n lines.push(` <loc>${escapeXml(`${host}/sitemap/${id}.xml`)}</loc>`);\n lines.push(` <lastmod>${lastmod}</lastmod>`);\n lines.push(' </sitemap>');\n }\n lines.push('</sitemapindex>');\n return lines.join('\\n');\n}\n\nfunction escapeXml(s: string): string {\n return s\n .replace(/&/g, '&')\n .replace(/</g, '<')\n .replace(/>/g, '>')\n .replace(/\"/g, '"')\n .replace(/'/g, ''');\n}\n","/**\n * createRobots — factory for Next.js `app/robots.ts`.\n *\n * Emits a single `User-Agent: *` block plus a Sitemap pointer. Disallow\n * defaults cover the common cases (auth, account, API surface) — override\n * per app if needed.\n *\n * The `sitemap` default `${host}/sitemap.xml` only makes sense for the\n * single-file case. When the app uses `createDjangoSitemap`\n * (`generateSitemaps()` → chunked output) **plus** `createSitemapIndex`\n * — the canonical pairing — pass an explicit\n * `sitemap: \\`${host}/sitemap_index.xml\\`` to point crawlers at the\n * index. The default `/sitemap.xml` will serve a 404 in that setup\n * (Next.js reserves it for the metadata route but doesn't emit content\n * there).\n *\n * Usage:\n *\n * ```ts\n * // apps/<app>/app/robots.ts\n * import { createRobots } from '@djangocfg/nextjs/sitemap';\n *\n * const host = process.env.NEXT_PUBLIC_SITE_URL!;\n *\n * export default createRobots({\n * host,\n * disallow: ['/account/', '/auth', '/api/', '/apix/'],\n * sitemap: `${host}/sitemap_index.xml`, // matches createSitemapIndex\n * });\n * ```\n */\n\nimport type { MetadataRoute } from 'next';\n\nimport type { CreateRobotsOptions } from './types';\n\nconst DEFAULT_DISALLOW = ['/account/', '/auth', '/api/'];\n\nexport function createRobots(opts: CreateRobotsOptions): () => MetadataRoute.Robots {\n const { host, disallow = DEFAULT_DISALLOW, sitemap } = opts;\n return () => ({\n rules: [{ userAgent: '*', allow: '/', disallow }],\n sitemap: sitemap ?? `${host}/sitemap.xml`,\n host,\n });\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/sitemap/constants.ts","../../src/sitemap/fetch.ts","../../src/sitemap/ids.ts","../../src/sitemap/select.ts","../../src/sitemap/sitemap.ts","../../src/sitemap/index-route.ts","../../src/sitemap/robots.ts"],"sourcesContent":["/**\n * Shared sitemap constants.\n *\n * Pulled out so both the chunk factory (`createDjangoSitemap`) and the\n * index Route Handler (`createSitemapIndex`) agree on the id of the\n * frontend-only static-routes chunk.\n */\n\nexport const STATIC_ID = 'static';\nexport const DEFAULT_INDEX_REVALIDATE = 600;\nexport const DEFAULT_FEED_REVALIDATE = 3600;\n","/**\n * Sitemap fetch helpers — never throw.\n *\n * Failures are converted to empty payloads so a transient Django outage\n * doesn't break `next build`. The next ISR cycle (`revalidate`) recovers\n * automatically.\n */\n\nimport type { SitemapFeedPage, SitemapIndex } from './types';\n\n/**\n * Next.js refuses to write a fetch response larger than 2MB into its data\n * cache. It logs one line and serves the response uncached, so `revalidate`\n * silently becomes a no-op and every regeneration refetches the full payload\n * from Django. Nothing else surfaces this, so we measure the body ourselves.\n */\nconst NEXT_DATA_CACHE_LIMIT = 2 * 1024 * 1024;\n\n// One warning per URL: `sitemap()` runs once per chunk per build, and a\n// repeated line for the same feed reads as a new problem rather than the\n// same one.\nconst oversizeWarned = new Set<string>();\n\n/**\n * Read the body as text so its exact size is known, then parse.\n *\n * `Content-Length` is not a substitute: it is absent under chunked transfer\n * and reports the compressed size when the response is gzipped, while the\n * 2MB ceiling applies to the decoded bytes Next.js would store.\n */\nasync function parseAndCheckSize<T>(r: Response, url: string, label: string): Promise<T> {\n const text = await r.text();\n const bytes = new TextEncoder().encode(text).length;\n if (bytes > NEXT_DATA_CACHE_LIMIT && !oversizeWarned.has(url)) {\n oversizeWarned.add(url);\n console.warn(\n `[sitemap] ${label} is ${bytes} bytes, over Next.js's 2MB data-cache ceiling: ` +\n `revalidate is inert for this feed and every regeneration refetches it from Django. ` +\n `Lower the backend page_size if the extra load matters — note 50,000 URLs is the ` +\n `sitemap protocol's own maximum, so a large payload here is not itself a bug.`,\n );\n }\n return JSON.parse(text) as T;\n}\n\nconst EMPTY_INDEX: SitemapIndex = {\n sources: [],\n generated_at: new Date(0).toISOString(),\n ttl_seconds: 60,\n};\n\nconst emptyFeed = (source: string, cursor: string | null): SitemapFeedPage => ({\n source,\n chunk_id: `${source}-empty`,\n count: 0,\n has_more: false,\n next_cursor: cursor,\n entries: [],\n});\n\nexport async function fetchSitemapIndex(\n apiUrl: string,\n revalidate: number,\n): Promise<SitemapIndex> {\n const url = `${apiUrl}/cfg/sitemap/index/`;\n try {\n const r = await fetch(url, {\n next: { revalidate },\n });\n if (!r.ok) {\n console.warn(`[sitemap] index fetch returned ${r.status}; falling back to empty`);\n return EMPTY_INDEX;\n }\n return await parseAndCheckSize<SitemapIndex>(r, url, 'index');\n } catch (err) {\n console.warn('[sitemap] index fetch failed:', err);\n return EMPTY_INDEX;\n }\n}\n\nexport async function fetchSitemapFeed(\n apiUrl: string,\n source: string,\n cursor: string | null,\n revalidate: number,\n): Promise<SitemapFeedPage> {\n const params = new URLSearchParams({ source });\n if (cursor) params.set('cursor', cursor);\n const url = `${apiUrl}/cfg/sitemap/feed/?${params}`;\n try {\n const r = await fetch(url, {\n next: { revalidate },\n });\n if (!r.ok) {\n console.warn(`[sitemap] feed ${source} returned ${r.status}; falling back to empty`);\n return emptyFeed(source, cursor);\n }\n return await parseAndCheckSize<SitemapFeedPage>(r, url, `feed ${source}`);\n } catch (err) {\n console.warn(`[sitemap] feed ${source} fetch failed:`, err);\n return emptyFeed(source, cursor);\n }\n}\n","/**\n * Sitemap chunk id encoding.\n *\n * Each Next.js sitemap file is addressed by an opaque string id. We\n * round-trip a `(source, cursor)` pair through that id so the default\n * sitemap handler knows which Django chunk to fetch.\n *\n * Separator `--` is unambiguous: backend cursors use URL-safe base64 (`-_`).\n */\n\nexport function encodeChunkId(source: string, cursor: string | null): string {\n return `${source}--${cursor ?? ''}`;\n}\n\nexport function decodeChunkId(id: string): { source: string; cursor: string | null } {\n const sep = id.indexOf('--');\n if (sep === -1) return { source: id, cursor: null };\n const source = id.slice(0, sep);\n const cursorRaw = id.slice(sep + 2);\n return { source, cursor: cursorRaw === '' ? null : cursorRaw };\n}\n","/**\n * Which backend sources a frontend claims.\n *\n * `GET /cfg/sitemap/index/` answers for the whole Django project — every\n * source any installed app registered. One frontend is not always entitled\n * to all of them: a backend serving several brands registers a source per\n * brand (`properties-caribbean`, `properties-bali`), and each brand's app\n * must emit only its own, or it advertises the other's URLs under its own\n * host — every one of them a 404, and the sitemap that carries them is\n * discredited whole.\n *\n * The selector lives here, shared, for the same reason `STATIC_ID` does:\n * `createDjangoSitemap` enumerates the chunks and `createSitemapIndex`\n * enumerates them again for the index document. If the two applied\n * different filters, the index would advertise shards the chunk route\n * refuses to build — and that mismatch is invisible until a crawler reads\n * both. One function, called by both, cannot drift.\n *\n * Source NAMES are the contract. The module ships no notion of a brand or\n * a tenant, and it should not: the backend chose what its source names\n * mean, and only the app knows which of them are its own.\n */\n\nimport type { SitemapIndex, SitemapSourceInfo, SourceSelector } from './types';\n\n/**\n * A predicate from whatever the caller passed.\n *\n * Omitted means every source — the single-tenant default, and what every\n * caller before this option existed already relied on.\n */\nexport function sourceFilter(select: SourceSelector | undefined): (name: string) => boolean {\n if (select === undefined) return () => true;\n if (typeof select === 'function') return select;\n const allowed = new Set(select);\n return (name) => allowed.has(name);\n}\n\n/** The index's sources, narrowed to those the caller claims. */\nexport function selectSources(\n index: SitemapIndex,\n select: SourceSelector | undefined,\n): SitemapSourceInfo[] {\n const keep = sourceFilter(select);\n return index.sources.filter((source) => keep(source.name));\n}\n\n/**\n * Every chunk id the selected sources produce, in index order.\n *\n * The one place `(source, chunk)` becomes an id, so the chunk route and the\n * index document enumerate the same set by construction.\n */\nexport function selectedChunkIds(\n index: SitemapIndex,\n select: SourceSelector | undefined,\n encode: (source: string, cursor: string | null) => string,\n): string[] {\n const ids: string[] = [];\n for (const source of selectSources(index, select)) {\n for (const chunk of source.chunks) {\n ids.push(encode(source.name, chunk.cursor_to));\n }\n }\n return ids;\n}\n\n/**\n * A selector for one tenant of a multi-brand backend.\n *\n * Matches the `<source>-<tenant>` names that a backend registering per\n * tenant emits. It splits on the LAST hyphen and compares the tail exactly,\n * rather than testing a suffix: `endsWith('-bali')` is correct for\n * `properties-bali` but the same shape reads `pricing-plans-premium` as\n * belonging to a `premium` tenant, and a substring match is not a contract.\n *\n * ```ts\n * sources: tenantSources('bali') // properties-bali, communities-bali, …\n * ```\n *\n * Sources with no hyphen never match: an untenanted source belongs to the\n * project rather than to a brand, and claiming it for one is the error this\n * function exists to prevent.\n */\nexport function tenantSources(tenant: string): (name: string) => boolean {\n return (name) => {\n const sep = name.lastIndexOf('-');\n return sep !== -1 && name.slice(sep + 1) === tenant;\n };\n}\n","/**\n * createDjangoSitemap — factory for Next.js `app/sitemap.ts`.\n *\n * Backed by `django_cfg.modules.django_sitemap`: the backend exposes a\n * paginated JSON feed, this factory turns each chunk into a Next.js\n * sitemap file via `generateSitemaps()`.\n *\n * Usage:\n *\n * ```ts\n * // apps/<app>/app/sitemap.ts\n * import { createDjangoSitemap } from '@djangocfg/nextjs/sitemap';\n *\n * const { generateSitemaps, sitemap } = createDjangoSitemap({\n * host: process.env.NEXT_PUBLIC_SITE_URL!,\n * apiUrl: process.env.NEXT_PUBLIC_API_URL!,\n * staticRoutes: [\n * { path: '/', changeFrequency: 'daily', priority: 1.0 },\n * { path: '/catalog', changeFrequency: 'daily', priority: 0.9 },\n * ],\n * });\n *\n * export { generateSitemaps, sitemap as default };\n * export const revalidate = 3600;\n * ```\n *\n * Without `apiUrl`, the sitemap emits only the static routes — handy for\n * marketing sites that don't have a backend-driven URL space.\n */\n\nimport type { MetadataRoute } from 'next';\n\nimport {\n DEFAULT_FEED_REVALIDATE,\n DEFAULT_INDEX_REVALIDATE,\n STATIC_ID,\n} from './constants';\nimport { fetchSitemapFeed, fetchSitemapIndex } from './fetch';\nimport { decodeChunkId, encodeChunkId } from './ids';\nimport { selectedChunkIds, sourceFilter } from './select';\n\nimport type { CreateDjangoSitemapOptions, StaticRoute } from './types';\n\ninterface SitemapApi {\n generateSitemaps: () => Promise<Array<{ id: string }>>;\n sitemap: (props: { id: Promise<string> }) => Promise<MetadataRoute.Sitemap>;\n}\n\nexport function createDjangoSitemap(opts: CreateDjangoSitemapOptions): SitemapApi {\n const {\n host,\n apiUrl,\n staticRoutes = [],\n sources,\n indexRevalidate = DEFAULT_INDEX_REVALIDATE,\n feedRevalidate = DEFAULT_FEED_REVALIDATE,\n } = opts;\n\n return {\n async generateSitemaps() {\n const ids: Array<{ id: string }> = [{ id: STATIC_ID }];\n if (!apiUrl) return ids;\n const index = await fetchSitemapIndex(apiUrl, indexRevalidate);\n for (const id of selectedChunkIds(index, sources, encodeChunkId)) {\n ids.push({ id });\n }\n return ids;\n },\n\n async sitemap({ id: idPromise }) {\n const id = await idPromise;\n\n if (id === STATIC_ID) {\n return renderStatic(host, staticRoutes);\n }\n\n if (!apiUrl) return [];\n\n const { source, cursor } = decodeChunkId(id);\n // A chunk this app does not claim renders empty rather than fetching.\n // `generateSitemaps` never mints such an id, but the route stays\n // reachable by hand, and serving another tenant's URLs from a guessed\n // address would undo the filter above.\n if (!sourceFilter(sources)(source)) return [];\n\n const feed = await fetchSitemapFeed(apiUrl, source, cursor, feedRevalidate);\n return feed.entries.map((e) => ({\n url: `${host}${e.loc}`,\n lastModified: e.lastmod ? new Date(e.lastmod) : undefined,\n }));\n },\n };\n}\n\nfunction renderStatic(host: string, routes: StaticRoute[]): MetadataRoute.Sitemap {\n return routes.map((r) => ({\n url: `${host}${r.path}`,\n changeFrequency: r.changeFrequency,\n priority: r.priority,\n }));\n}\n","/**\n * createSitemapIndex — Route Handler factory for the sitemap-index XML.\n *\n * Next.js's `generateSitemaps()` API ships chunks at `/sitemap/<id>.xml`\n * but does NOT emit a single sitemap-index document. Without one,\n * robots.txt + crawlers point at a 404 and never discover the chunks.\n * This factory closes that gap: it returns a `GET` Route Handler that\n * mints a `<sitemapindex>` XML listing every chunk the matching\n * `createDjangoSitemap` would produce.\n *\n * Mount it at **`app/sitemap_index.xml/route.ts`** — NOT `sitemap.xml`.\n * `app/sitemap.ts` (where `createDjangoSitemap` lives) already owns\n * `/sitemap.xml` in Next.js's metadata routing, even when its output is\n * fan-out via `generateSitemaps()` and the singular URL serves 404.\n * Putting a route handler at `app/sitemap.xml/route.ts` throws at build\n * time: *Conflicting route and metadata at /sitemap.xml*.\n *\n * Usage — the full pattern with the matching chunk + robots factories:\n *\n * ```ts\n * // apps/<app>/app/sitemap.ts — chunks\n * import { createDjangoSitemap } from '@djangocfg/nextjs/sitemap';\n * const { generateSitemaps, sitemap } = createDjangoSitemap({ ... });\n * export { generateSitemaps, sitemap as default };\n *\n * // apps/<app>/app/sitemap_index.xml/route.ts — the index\n * import { createSitemapIndex } from '@djangocfg/nextjs/sitemap';\n * export const { GET } = createSitemapIndex({\n * host: process.env.NEXT_PUBLIC_SITE_URL!,\n * apiUrl: process.env.NEXT_PUBLIC_API_URL!,\n * });\n * export const revalidate = 600;\n *\n * // apps/<app>/app/robots.ts — point crawlers at the index URL above\n * import { createRobots } from '@djangocfg/nextjs/sitemap';\n * const host = process.env.NEXT_PUBLIC_SITE_URL!;\n * export default createRobots({\n * host,\n * sitemap: `${host}/sitemap_index.xml`,\n * });\n * ```\n *\n * Keep `host` / `apiUrl` in sync with the same app's `app/sitemap.ts`\n * — the index just enumerates the chunks the other file emits.\n */\n\nimport {\n DEFAULT_INDEX_REVALIDATE,\n STATIC_ID,\n} from './constants';\nimport { fetchSitemapIndex } from './fetch';\nimport { encodeChunkId } from './ids';\nimport { selectedChunkIds } from './select';\n\nimport type { CreateSitemapIndexOptions } from './types';\n\ninterface SitemapIndexRoute {\n GET: () => Promise<Response>;\n}\n\nexport function createSitemapIndex(opts: CreateSitemapIndexOptions): SitemapIndexRoute {\n const {\n host,\n apiUrl,\n sources,\n indexRevalidate = DEFAULT_INDEX_REVALIDATE,\n includeStatic = true,\n } = opts;\n\n return {\n async GET() {\n const ids: string[] = includeStatic ? [STATIC_ID] : [];\n\n if (apiUrl) {\n const index = await fetchSitemapIndex(apiUrl, indexRevalidate);\n ids.push(...selectedChunkIds(index, sources, encodeChunkId));\n }\n\n const lastmod = new Date().toISOString();\n const body = renderIndex(host, ids, lastmod);\n\n return new Response(body, {\n status: 200,\n headers: {\n 'content-type': 'application/xml; charset=utf-8',\n // Mirror the per-chunk revalidate so a cached CDN edge expires\n // at the same cadence as the index it points at.\n 'cache-control': `public, s-maxage=${indexRevalidate}, stale-while-revalidate=${indexRevalidate}`,\n },\n });\n },\n };\n}\n\nfunction renderIndex(host: string, ids: string[], lastmod: string): string {\n const lines: string[] = [];\n lines.push('<?xml version=\"1.0\" encoding=\"UTF-8\"?>');\n lines.push('<sitemapindex xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">');\n for (const id of ids) {\n lines.push(' <sitemap>');\n lines.push(` <loc>${escapeXml(`${host}/sitemap/${id}.xml`)}</loc>`);\n lines.push(` <lastmod>${lastmod}</lastmod>`);\n lines.push(' </sitemap>');\n }\n lines.push('</sitemapindex>');\n return lines.join('\\n');\n}\n\nfunction escapeXml(s: string): string {\n return s\n .replace(/&/g, '&')\n .replace(/</g, '<')\n .replace(/>/g, '>')\n .replace(/\"/g, '"')\n .replace(/'/g, ''');\n}\n","/**\n * createRobots — factory for Next.js `app/robots.ts`.\n *\n * Emits a single `User-Agent: *` block plus a Sitemap pointer. Disallow\n * defaults cover the common cases (auth, account, API surface) — override\n * per app if needed.\n *\n * The `sitemap` default `${host}/sitemap.xml` only makes sense for the\n * single-file case. When the app uses `createDjangoSitemap`\n * (`generateSitemaps()` → chunked output) **plus** `createSitemapIndex`\n * — the canonical pairing — pass an explicit\n * `sitemap: \\`${host}/sitemap_index.xml\\`` to point crawlers at the\n * index. The default `/sitemap.xml` will serve a 404 in that setup\n * (Next.js reserves it for the metadata route but doesn't emit content\n * there).\n *\n * Usage:\n *\n * ```ts\n * // apps/<app>/app/robots.ts\n * import { createRobots } from '@djangocfg/nextjs/sitemap';\n *\n * const host = process.env.NEXT_PUBLIC_SITE_URL!;\n *\n * export default createRobots({\n * host,\n * disallow: ['/account/', '/auth', '/api/', '/apix/'],\n * sitemap: `${host}/sitemap_index.xml`, // matches createSitemapIndex\n * });\n * ```\n */\n\nimport type { MetadataRoute } from 'next';\n\nimport type { CreateRobotsOptions } from './types';\n\nconst DEFAULT_DISALLOW = ['/account/', '/auth', '/api/'];\n\nexport function createRobots(opts: CreateRobotsOptions): () => MetadataRoute.Robots {\n const { host, disallow = DEFAULT_DISALLOW, sitemap } = opts;\n return () => ({\n rules: [{ userAgent: '*', allow: '/', disallow }],\n sitemap: sitemap ?? `${host}/sitemap.xml`,\n host,\n });\n}\n"],"mappings":"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","names":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/nextjs",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.555",
|
|
4
4
|
"description": "Next.js server utilities: sitemap, health, OG images, contact forms, navigation, config",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"nextjs",
|
|
@@ -149,9 +149,9 @@
|
|
|
149
149
|
"ai-docs": "tsx src/ai/cli.ts"
|
|
150
150
|
},
|
|
151
151
|
"peerDependencies": {
|
|
152
|
-
"@djangocfg/devtools": "^2.1.
|
|
153
|
-
"@djangocfg/i18n": "^2.1.
|
|
154
|
-
"@djangocfg/ui-core": "^2.1.
|
|
152
|
+
"@djangocfg/devtools": "^2.1.555",
|
|
153
|
+
"@djangocfg/i18n": "^2.1.555",
|
|
154
|
+
"@djangocfg/ui-core": "^2.1.555",
|
|
155
155
|
"next": "^16.2.2"
|
|
156
156
|
},
|
|
157
157
|
"peerDependenciesMeta": {
|
|
@@ -170,11 +170,11 @@
|
|
|
170
170
|
"semver": "^7.8.5"
|
|
171
171
|
},
|
|
172
172
|
"devDependencies": {
|
|
173
|
-
"@djangocfg/devtools": "^2.1.
|
|
174
|
-
"@djangocfg/i18n": "^2.1.
|
|
175
|
-
"@djangocfg/layouts": "^2.1.
|
|
176
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
177
|
-
"@djangocfg/ui-core": "^2.1.
|
|
173
|
+
"@djangocfg/devtools": "^2.1.555",
|
|
174
|
+
"@djangocfg/i18n": "^2.1.555",
|
|
175
|
+
"@djangocfg/layouts": "^2.1.555",
|
|
176
|
+
"@djangocfg/typescript-config": "^2.1.555",
|
|
177
|
+
"@djangocfg/ui-core": "^2.1.555",
|
|
178
178
|
"@types/node": "^25.9.5",
|
|
179
179
|
"@types/react": "19.2.15",
|
|
180
180
|
"@types/react-dom": "19.2.3",
|
package/src/og-image/README.md
CHANGED
|
@@ -71,10 +71,25 @@ export default async function Image({ params }) {
|
|
|
71
71
|
}
|
|
72
72
|
```
|
|
73
73
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
74
|
+
**The route alone is not enough.** Next.js applies the file convention only
|
|
75
|
+
when a page's metadata leaves `openGraph.images` unset — an explicit value
|
|
76
|
+
wins, and the route is then silently never rendered. `createMetadata` always
|
|
77
|
+
writes `openGraph.images` (both `OgSpec` cases resolve to an image), so adding
|
|
78
|
+
`opengraph-image.tsx` under a page built by this factory does nothing.
|
|
79
|
+
|
|
80
|
+
To actually get the card, strip the key the factory set:
|
|
81
|
+
|
|
82
|
+
```tsx
|
|
83
|
+
// app/blog/[slug]/page.tsx
|
|
84
|
+
const { openGraph, twitter, ...rest } = createMetadata({ title: p.name, path })
|
|
85
|
+
const { images: _og, ...og } = openGraph ?? {}
|
|
86
|
+
const { images: _tw, ...tw } = twitter ?? {}
|
|
87
|
+
export const metadata = { ...rest, openGraph: og, twitter: tw }
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
The factory has no "dynamic" case because the card is Next.js's job to render,
|
|
91
|
+
not this package's — but the two mechanisms do compete for the same tag, and
|
|
92
|
+
the explicit one wins.
|
|
78
93
|
|
|
79
94
|
---
|
|
80
95
|
|
|
@@ -47,8 +47,10 @@ export interface PageMeta {
|
|
|
47
47
|
* - `'static'` brand image from config (default)
|
|
48
48
|
* - `{ image, alt? }` an exact image URL (e.g. a content screenshot)
|
|
49
49
|
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
50
|
+
* Every case here writes `openGraph.images`, which SUPPRESSES an
|
|
51
|
+
* `opengraph-image.tsx` route for the page — Next.js honours that file
|
|
52
|
+
* convention only when metadata sets no images. A per-page rendered card
|
|
53
|
+
* therefore needs metadata that omits the key, not just the route file.
|
|
52
54
|
*/
|
|
53
55
|
og?: OgSpec
|
|
54
56
|
/** Escape hatch: extra Metadata fields merged last (robots, authors, etc.). */
|
package/src/og-image/metadata.ts
CHANGED
|
@@ -37,9 +37,12 @@ export function extractTitle(metadata: Metadata): string {
|
|
|
37
37
|
* This is the single decision point for "what is the OG image" — no other code
|
|
38
38
|
* branches on the spec.
|
|
39
39
|
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
40
|
+
* An `opengraph-image` route does NOT override this. Next.js applies that file
|
|
41
|
+
* convention only when metadata leaves `openGraph.images` unset, and this
|
|
42
|
+
* function always sets it — so while `createMetadata` runs for a page, its
|
|
43
|
+
* `opengraph-image.tsx` is silently never rendered. To use a per-page card, the
|
|
44
|
+
* page must keep `openGraph.images` out of its metadata entirely (build it
|
|
45
|
+
* without this function, or delete the key afterwards).
|
|
43
46
|
*/
|
|
44
47
|
export function resolveOgImage(
|
|
45
48
|
spec: OgSpec,
|
package/src/og-image/types.ts
CHANGED
|
@@ -6,7 +6,11 @@
|
|
|
6
6
|
*
|
|
7
7
|
* There is deliberately no "render this title onto a card" case. That was
|
|
8
8
|
* served by a Django endpoint (`/cfg/og/<base64>/`), archived 2026-09-03 —
|
|
9
|
-
* Next.js renders such cards itself via an `opengraph-image` route
|
|
10
|
-
*
|
|
9
|
+
* Next.js renders such cards itself via an `opengraph-image` route.
|
|
10
|
+
*
|
|
11
|
+
* That route is not a drop-in, though: both spec cases set `openGraph.images`,
|
|
12
|
+
* and Next.js skips the file convention whenever metadata already does. A page
|
|
13
|
+
* wanting a rendered card must omit the key from its metadata (see
|
|
14
|
+
* `resolveOgImage`), not merely add the route file.
|
|
11
15
|
*/
|
|
12
16
|
export type OgSpec = 'static' | { image: string; alt?: string }
|
package/src/sitemap/fetch.ts
CHANGED
|
@@ -8,6 +8,41 @@
|
|
|
8
8
|
|
|
9
9
|
import type { SitemapFeedPage, SitemapIndex } from './types';
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Next.js refuses to write a fetch response larger than 2MB into its data
|
|
13
|
+
* cache. It logs one line and serves the response uncached, so `revalidate`
|
|
14
|
+
* silently becomes a no-op and every regeneration refetches the full payload
|
|
15
|
+
* from Django. Nothing else surfaces this, so we measure the body ourselves.
|
|
16
|
+
*/
|
|
17
|
+
const NEXT_DATA_CACHE_LIMIT = 2 * 1024 * 1024;
|
|
18
|
+
|
|
19
|
+
// One warning per URL: `sitemap()` runs once per chunk per build, and a
|
|
20
|
+
// repeated line for the same feed reads as a new problem rather than the
|
|
21
|
+
// same one.
|
|
22
|
+
const oversizeWarned = new Set<string>();
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Read the body as text so its exact size is known, then parse.
|
|
26
|
+
*
|
|
27
|
+
* `Content-Length` is not a substitute: it is absent under chunked transfer
|
|
28
|
+
* and reports the compressed size when the response is gzipped, while the
|
|
29
|
+
* 2MB ceiling applies to the decoded bytes Next.js would store.
|
|
30
|
+
*/
|
|
31
|
+
async function parseAndCheckSize<T>(r: Response, url: string, label: string): Promise<T> {
|
|
32
|
+
const text = await r.text();
|
|
33
|
+
const bytes = new TextEncoder().encode(text).length;
|
|
34
|
+
if (bytes > NEXT_DATA_CACHE_LIMIT && !oversizeWarned.has(url)) {
|
|
35
|
+
oversizeWarned.add(url);
|
|
36
|
+
console.warn(
|
|
37
|
+
`[sitemap] ${label} is ${bytes} bytes, over Next.js's 2MB data-cache ceiling: ` +
|
|
38
|
+
`revalidate is inert for this feed and every regeneration refetches it from Django. ` +
|
|
39
|
+
`Lower the backend page_size if the extra load matters — note 50,000 URLs is the ` +
|
|
40
|
+
`sitemap protocol's own maximum, so a large payload here is not itself a bug.`,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
return JSON.parse(text) as T;
|
|
44
|
+
}
|
|
45
|
+
|
|
11
46
|
const EMPTY_INDEX: SitemapIndex = {
|
|
12
47
|
sources: [],
|
|
13
48
|
generated_at: new Date(0).toISOString(),
|
|
@@ -27,15 +62,16 @@ export async function fetchSitemapIndex(
|
|
|
27
62
|
apiUrl: string,
|
|
28
63
|
revalidate: number,
|
|
29
64
|
): Promise<SitemapIndex> {
|
|
65
|
+
const url = `${apiUrl}/cfg/sitemap/index/`;
|
|
30
66
|
try {
|
|
31
|
-
const r = await fetch(
|
|
67
|
+
const r = await fetch(url, {
|
|
32
68
|
next: { revalidate },
|
|
33
69
|
});
|
|
34
70
|
if (!r.ok) {
|
|
35
71
|
console.warn(`[sitemap] index fetch returned ${r.status}; falling back to empty`);
|
|
36
72
|
return EMPTY_INDEX;
|
|
37
73
|
}
|
|
38
|
-
return
|
|
74
|
+
return await parseAndCheckSize<SitemapIndex>(r, url, 'index');
|
|
39
75
|
} catch (err) {
|
|
40
76
|
console.warn('[sitemap] index fetch failed:', err);
|
|
41
77
|
return EMPTY_INDEX;
|
|
@@ -50,15 +86,16 @@ export async function fetchSitemapFeed(
|
|
|
50
86
|
): Promise<SitemapFeedPage> {
|
|
51
87
|
const params = new URLSearchParams({ source });
|
|
52
88
|
if (cursor) params.set('cursor', cursor);
|
|
89
|
+
const url = `${apiUrl}/cfg/sitemap/feed/?${params}`;
|
|
53
90
|
try {
|
|
54
|
-
const r = await fetch(
|
|
91
|
+
const r = await fetch(url, {
|
|
55
92
|
next: { revalidate },
|
|
56
93
|
});
|
|
57
94
|
if (!r.ok) {
|
|
58
95
|
console.warn(`[sitemap] feed ${source} returned ${r.status}; falling back to empty`);
|
|
59
96
|
return emptyFeed(source, cursor);
|
|
60
97
|
}
|
|
61
|
-
return
|
|
98
|
+
return await parseAndCheckSize<SitemapFeedPage>(r, url, `feed ${source}`);
|
|
62
99
|
} catch (err) {
|
|
63
100
|
console.warn(`[sitemap] feed ${source} fetch failed:`, err);
|
|
64
101
|
return emptyFeed(source, cursor);
|