@djangocfg/nextjs 2.1.470 → 2.1.471

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.
@@ -80,7 +80,11 @@ interface PageMeta {
80
80
  keywords?: string[];
81
81
  /** Current locale, used for canonical + openGraph.locale + alternates. */
82
82
  locale?: string;
83
- /** Path without locale prefix, e.g. "/skills". '' for home. */
83
+ /**
84
+ * Path without locale prefix, e.g. "/skills" or "/skills/foo". Home is ''
85
+ * (or '/'). Leading/trailing slashes are normalized, so both canonical and
86
+ * og:url come out well-formed whichever you pass.
87
+ */
84
88
  path?: string;
85
89
  /**
86
90
  * What the OG image should be. Defaults to `'static'` (the brand image).
@@ -70,24 +70,29 @@ function withOgImage(metadata, params = {}) {
70
70
  }
71
71
 
72
72
  // src/og-image/metadata-factory.ts
73
+ function buildUrl(siteUrl, locale, path) {
74
+ const origin = siteUrl.replace(/\/+$/, "");
75
+ const segments = [locale, path].map((segment) => segment?.replace(/^\/+|\/+$/g, "") ?? "").filter(Boolean);
76
+ return segments.length ? `${origin}/${segments.join("/")}` : origin;
77
+ }
73
78
  function buildAlternates(config, path, locale) {
74
79
  if (!config.locales?.length) {
75
- return { canonical: `${config.siteUrl}/${path}`.replace(/\/+$/, "") || config.siteUrl };
80
+ return { canonical: buildUrl(config.siteUrl, void 0, path) };
76
81
  }
77
82
  const languages = {};
78
83
  for (const loc of config.locales) {
79
- languages[loc] = `${config.siteUrl}/${loc}${path}`;
84
+ languages[loc] = buildUrl(config.siteUrl, loc, path);
80
85
  }
81
86
  const xDefault = config.defaultLocale ?? (config.locales.includes("en") ? "en" : config.locales[0]);
82
- languages["x-default"] = `${config.siteUrl}/${xDefault}${path}`;
83
- return { canonical: `${config.siteUrl}/${locale}${path}`, languages };
87
+ languages["x-default"] = buildUrl(config.siteUrl, xDefault, path);
88
+ return { canonical: buildUrl(config.siteUrl, locale, path), languages };
84
89
  }
85
90
  function createMetadata(config, page = {}) {
86
91
  const title = page.title ?? config.name;
87
92
  const description = page.description ?? config.description;
88
93
  const locale = page.locale ?? config.defaultLocale ?? "en";
89
94
  const path = page.path ?? "";
90
- const ogUrl = config.locales?.length ? `${config.siteUrl}/${locale}${path}` : `${config.siteUrl}${path}`;
95
+ const ogUrl = buildUrl(config.siteUrl, config.locales?.length ? locale : void 0, path);
91
96
  const ogImage = resolveOgImage(page.og ?? "static", config.ogImage, title, config.ogBackend);
92
97
  const metadata = {
93
98
  title,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/og-image/url.ts","../../src/og-image/metadata.ts","../../src/og-image/metadata-factory.ts"],"sourcesContent":["import type { OGImageParams } from './types'\n\n/**\n * Resolves the base URL for og:image meta tags.\n * Must be publicly accessible by crawlers (Twitter, Facebook, etc).\n *\n * Priority:\n * 1. NEXT_PUBLIC_MEDIA_URL — explicit media domain (nginx proxy or CDN)\n * 2. NEXT_PUBLIC_API_URL — direct Django API domain\n * 3. NEXT_PUBLIC_SITE_URL — site domain (same-domain/static build)\n * 4. '' — relative URL (last resort)\n *\n * Use cases:\n * - Same domain (static build): API_URL='' → relative /cfg/og/...\n * - Split domain (standalone): API_URL=https://api.x.com → absolute\n * - Media via nginx proxy: MEDIA_URL=https://x.com, API_URL=https://api.x.com\n * - CDN: MEDIA_URL=https://cdn.x.com\n */\nfunction resolveOgPublicBase(): string {\n if (typeof process === 'undefined') return ''\n return (\n process.env.NEXT_PUBLIC_MEDIA_URL?.replace(/\\/$/, '') ??\n process.env.NEXT_PUBLIC_API_URL?.replace(/\\/$/, '') ??\n process.env.NEXT_PUBLIC_SITE_URL?.replace(/\\/$/, '') ??\n ''\n )\n}\n\nfunction encodeBase64(str: string): string {\n if (typeof Buffer !== 'undefined') {\n return Buffer.from(str).toString('base64url')\n }\n // Browser fallback\n return btoa(unescape(encodeURIComponent(str)))\n .replace(/\\+/g, '-')\n .replace(/\\//g, '_')\n .replace(/=+$/, '')\n}\n\nfunction cleanParams(params: OGImageParams): Record<string, unknown> {\n return Object.fromEntries(\n Object.entries(params).filter(\n ([, v]) => v !== undefined && v !== null && v !== ''\n )\n )\n}\n\n/**\n * Builds an absolute (or relative) OG image URL pointing to Django's\n * django_ogimage endpoint: /cfg/og/<base64params>/\n *\n * The URL is suitable for use in og:image and twitter:image meta tags.\n *\n * @param base Optional explicit backend origin (e.g. \"https://api.example.com\").\n * When omitted, it's resolved from env (MEDIA_URL → API_URL → SITE_URL → '').\n * Pass the `ogBackend` from your site config to avoid relying on env.\n */\nexport function buildOgUrl(params: OGImageParams, base?: string): string {\n const b64 = encodeBase64(JSON.stringify(cleanParams(params)))\n const origin = (base ?? resolveOgPublicBase()).replace(/\\/$/, '')\n return `${origin}/cfg/og/${b64}/`\n}\n","import type { Metadata } from 'next'\nimport type { OGImageParams, OgSpec } from './types'\nimport { buildOgUrl } from './url'\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. Both static URLs and the dynamic\n * renderer funnel through here, so there's a single source of truth.\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/**\n * Metadata fragment whose OG image is rendered by Django's django_ogimage\n * endpoint (/cfg/og/). Low-level — most callers use `createMetadata`'s `og`\n * field instead.\n */\nexport function createOgMetadata(params: OGImageParams): Metadata {\n return ogImageFragment(buildOgUrl(params), params.title)\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` against a base Metadata into a concrete og/twitter image\n * fragment. `staticUrl` is the site's brand image, used for the `'static'` case.\n *\n * `backend` is the Django origin that serves the dynamic renderer. The dynamic\n * cases (`'dynamic'`, `{ render }`) require it: when it's `undefined`/empty —\n * e.g. a frontend with no Django backend — they **fall back to the static brand\n * image** instead of emitting a broken `/cfg/og/` URL. (`undefined` still lets\n * `buildOgUrl` fall back to env vars; pass `''` to force the static fallback.)\n *\n * This is the single decision point for \"what is the OG image\" — no other code\n * branches on the spec.\n */\nexport function resolveOgImage(\n spec: OgSpec,\n staticUrl: string,\n fallbackTitle: string,\n backend?: string\n): Pick<Metadata, 'openGraph' | 'twitter'> {\n const canRenderDynamic = backend === undefined || backend !== ''\n\n if (spec === 'static') {\n return ogImageFragment(staticUrl, fallbackTitle)\n }\n if (spec === 'dynamic') {\n return canRenderDynamic\n ? ogImageFragment(buildOgUrl({ title: fallbackTitle }, backend), fallbackTitle)\n : ogImageFragment(staticUrl, fallbackTitle)\n }\n if ('image' in spec) {\n return ogImageFragment(spec.image, spec.alt ?? fallbackTitle)\n }\n // { render: {...} } — dynamic with explicit params; title defaults to page title.\n const title = spec.render.title ?? fallbackTitle\n return canRenderDynamic\n ? ogImageFragment(buildOgUrl({ ...spec.render, title }, backend), title)\n : ogImageFragment(staticUrl, title)\n}\n\n/**\n * Merges OG image metadata (dynamic render) into existing Metadata. Kept for\n * low-level/manual use; `createMetadata` is the recommended entry point.\n *\n * return withOgImage({ title: 'Page' }, { preset: 'DARK_BLUE' })\n */\nexport function withOgImage(\n metadata: Metadata,\n params: Omit<OGImageParams, 'title'> & { title?: string } = {}\n): Metadata {\n const title = params.title ?? extractTitle(metadata)\n const frag = createOgMetadata({ ...params, title })\n return {\n ...metadata,\n openGraph: { ...metadata.openGraph, ...frag.openGraph },\n twitter: { ...metadata.twitter, ...frag.twitter },\n }\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 /**\n * Django origin that serves the dynamic OG renderer (`/cfg/og/`), e.g.\n * \"https://api.example.com\". Required for `og: 'dynamic'` / `{ render }`.\n *\n * - set → dynamic OG images render against this origin\n * - `''` → no backend: dynamic specs fall back to the static brand image\n * (use this for a frontend with no Django, so no broken URLs)\n * - omit → fall back to env vars (MEDIA_URL → API_URL → SITE_URL)\n */\n ogBackend?: 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 /** Path without locale prefix, e.g. \"/skills\". '' for home. */\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 * - `'dynamic'` render the page title onto the card\n * - `{ render: {...} }` render with explicit preset/layout/colors\n * - `{ image, alt? }` an exact image URL (e.g. a content screenshot)\n */\n og?: OgSpec\n /** Escape hatch: extra Metadata fields merged last (robots, authors, etc.). */\n extra?: Metadata\n}\n\nfunction buildAlternates(\n config: SiteMetaConfig,\n path: string,\n locale: string\n): Metadata['alternates'] {\n if (!config.locales?.length) {\n return { canonical: `${config.siteUrl}/${path}`.replace(/\\/+$/, '') || config.siteUrl }\n }\n const languages: Record<string, string> = {}\n for (const loc of config.locales) {\n languages[loc] = `${config.siteUrl}/${loc}${path}`\n }\n const xDefault =\n config.defaultLocale ?? (config.locales.includes('en') ? 'en' : config.locales[0])\n languages['x-default'] = `${config.siteUrl}/${xDefault}${path}`\n return { canonical: `${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 — title rendered onto the card\n * export const metadata = createMetadata({ title: post.title, path, og: 'dynamic' })\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 const ogUrl = config.locales?.length\n ? `${config.siteUrl}/${locale}${path}`\n : `${config.siteUrl}${path}`\n\n const ogImage = resolveOgImage(page.og ?? 'static', config.ogImage, title, config.ogBackend)\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/url.ts","../../src/og-image/metadata.ts","../../src/og-image/metadata-factory.ts"],"sourcesContent":["import type { OGImageParams } from './types'\n\n/**\n * Resolves the base URL for og:image meta tags.\n * Must be publicly accessible by crawlers (Twitter, Facebook, etc).\n *\n * Priority:\n * 1. NEXT_PUBLIC_MEDIA_URL — explicit media domain (nginx proxy or CDN)\n * 2. NEXT_PUBLIC_API_URL — direct Django API domain\n * 3. NEXT_PUBLIC_SITE_URL — site domain (same-domain/static build)\n * 4. '' — relative URL (last resort)\n *\n * Use cases:\n * - Same domain (static build): API_URL='' → relative /cfg/og/...\n * - Split domain (standalone): API_URL=https://api.x.com → absolute\n * - Media via nginx proxy: MEDIA_URL=https://x.com, API_URL=https://api.x.com\n * - CDN: MEDIA_URL=https://cdn.x.com\n */\nfunction resolveOgPublicBase(): string {\n if (typeof process === 'undefined') return ''\n return (\n process.env.NEXT_PUBLIC_MEDIA_URL?.replace(/\\/$/, '') ??\n process.env.NEXT_PUBLIC_API_URL?.replace(/\\/$/, '') ??\n process.env.NEXT_PUBLIC_SITE_URL?.replace(/\\/$/, '') ??\n ''\n )\n}\n\nfunction encodeBase64(str: string): string {\n if (typeof Buffer !== 'undefined') {\n return Buffer.from(str).toString('base64url')\n }\n // Browser fallback\n return btoa(unescape(encodeURIComponent(str)))\n .replace(/\\+/g, '-')\n .replace(/\\//g, '_')\n .replace(/=+$/, '')\n}\n\nfunction cleanParams(params: OGImageParams): Record<string, unknown> {\n return Object.fromEntries(\n Object.entries(params).filter(\n ([, v]) => v !== undefined && v !== null && v !== ''\n )\n )\n}\n\n/**\n * Builds an absolute (or relative) OG image URL pointing to Django's\n * django_ogimage endpoint: /cfg/og/<base64params>/\n *\n * The URL is suitable for use in og:image and twitter:image meta tags.\n *\n * @param base Optional explicit backend origin (e.g. \"https://api.example.com\").\n * When omitted, it's resolved from env (MEDIA_URL → API_URL → SITE_URL → '').\n * Pass the `ogBackend` from your site config to avoid relying on env.\n */\nexport function buildOgUrl(params: OGImageParams, base?: string): string {\n const b64 = encodeBase64(JSON.stringify(cleanParams(params)))\n const origin = (base ?? resolveOgPublicBase()).replace(/\\/$/, '')\n return `${origin}/cfg/og/${b64}/`\n}\n","import type { Metadata } from 'next'\nimport type { OGImageParams, OgSpec } from './types'\nimport { buildOgUrl } from './url'\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. Both static URLs and the dynamic\n * renderer funnel through here, so there's a single source of truth.\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/**\n * Metadata fragment whose OG image is rendered by Django's django_ogimage\n * endpoint (/cfg/og/). Low-level — most callers use `createMetadata`'s `og`\n * field instead.\n */\nexport function createOgMetadata(params: OGImageParams): Metadata {\n return ogImageFragment(buildOgUrl(params), params.title)\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` against a base Metadata into a concrete og/twitter image\n * fragment. `staticUrl` is the site's brand image, used for the `'static'` case.\n *\n * `backend` is the Django origin that serves the dynamic renderer. The dynamic\n * cases (`'dynamic'`, `{ render }`) require it: when it's `undefined`/empty —\n * e.g. a frontend with no Django backend — they **fall back to the static brand\n * image** instead of emitting a broken `/cfg/og/` URL. (`undefined` still lets\n * `buildOgUrl` fall back to env vars; pass `''` to force the static fallback.)\n *\n * This is the single decision point for \"what is the OG image\" — no other code\n * branches on the spec.\n */\nexport function resolveOgImage(\n spec: OgSpec,\n staticUrl: string,\n fallbackTitle: string,\n backend?: string\n): Pick<Metadata, 'openGraph' | 'twitter'> {\n const canRenderDynamic = backend === undefined || backend !== ''\n\n if (spec === 'static') {\n return ogImageFragment(staticUrl, fallbackTitle)\n }\n if (spec === 'dynamic') {\n return canRenderDynamic\n ? ogImageFragment(buildOgUrl({ title: fallbackTitle }, backend), fallbackTitle)\n : ogImageFragment(staticUrl, fallbackTitle)\n }\n if ('image' in spec) {\n return ogImageFragment(spec.image, spec.alt ?? fallbackTitle)\n }\n // { render: {...} } — dynamic with explicit params; title defaults to page title.\n const title = spec.render.title ?? fallbackTitle\n return canRenderDynamic\n ? ogImageFragment(buildOgUrl({ ...spec.render, title }, backend), title)\n : ogImageFragment(staticUrl, title)\n}\n\n/**\n * Merges OG image metadata (dynamic render) into existing Metadata. Kept for\n * low-level/manual use; `createMetadata` is the recommended entry point.\n *\n * return withOgImage({ title: 'Page' }, { preset: 'DARK_BLUE' })\n */\nexport function withOgImage(\n metadata: Metadata,\n params: Omit<OGImageParams, 'title'> & { title?: string } = {}\n): Metadata {\n const title = params.title ?? extractTitle(metadata)\n const frag = createOgMetadata({ ...params, title })\n return {\n ...metadata,\n openGraph: { ...metadata.openGraph, ...frag.openGraph },\n twitter: { ...metadata.twitter, ...frag.twitter },\n }\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 /**\n * Django origin that serves the dynamic OG renderer (`/cfg/og/`), e.g.\n * \"https://api.example.com\". Required for `og: 'dynamic'` / `{ render }`.\n *\n * - set → dynamic OG images render against this origin\n * - `''` → no backend: dynamic specs fall back to the static brand image\n * (use this for a frontend with no Django, so no broken URLs)\n * - omit → fall back to env vars (MEDIA_URL → API_URL → SITE_URL)\n */\n ogBackend?: 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 * - `'dynamic'` render the page title onto the card\n * - `{ render: {...} }` render with explicit preset/layout/colors\n * - `{ image, alt? }` an exact image URL (e.g. a content screenshot)\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 — title rendered onto the card\n * export const metadata = createMetadata({ title: post.title, path, og: 'dynamic' })\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, config.ogBackend)\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":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/nextjs",
3
- "version": "2.1.470",
3
+ "version": "2.1.471",
4
4
  "description": "Next.js server utilities: sitemap, health, OG images, contact forms, navigation, config",
5
5
  "keywords": [
6
6
  "nextjs",
@@ -140,12 +140,13 @@
140
140
  "clean": "rm -rf dist",
141
141
  "lint": "eslint .",
142
142
  "check": "tsc --noEmit",
143
+ "test": "tsx --test src/**/*.test.ts",
143
144
  "ai-docs": "tsx src/ai/cli.ts"
144
145
  },
145
146
  "peerDependencies": {
146
- "@djangocfg/devtools": "^2.1.470",
147
- "@djangocfg/i18n": "^2.1.470",
148
- "@djangocfg/ui-core": "^2.1.470",
147
+ "@djangocfg/devtools": "^2.1.471",
148
+ "@djangocfg/i18n": "^2.1.471",
149
+ "@djangocfg/ui-core": "^2.1.471",
149
150
  "next": "^16.2.2"
150
151
  },
151
152
  "peerDependenciesMeta": {
@@ -167,11 +168,11 @@
167
168
  "serwist": "^9.5.11"
168
169
  },
169
170
  "devDependencies": {
170
- "@djangocfg/devtools": "^2.1.470",
171
- "@djangocfg/i18n": "^2.1.470",
172
- "@djangocfg/layouts": "^2.1.470",
173
- "@djangocfg/typescript-config": "^2.1.470",
174
- "@djangocfg/ui-core": "^2.1.470",
171
+ "@djangocfg/devtools": "^2.1.471",
172
+ "@djangocfg/i18n": "^2.1.471",
173
+ "@djangocfg/layouts": "^2.1.471",
174
+ "@djangocfg/typescript-config": "^2.1.471",
175
+ "@djangocfg/ui-core": "^2.1.471",
175
176
  "@types/node": "^25.9.5",
176
177
  "@types/react": "19.2.15",
177
178
  "@types/react-dom": "19.2.3",
@@ -0,0 +1,136 @@
1
+ import assert from 'node:assert/strict'
2
+ import { test } from 'node:test'
3
+
4
+ import { createMetadata, type SiteMetaConfig } from './metadata-factory'
5
+
6
+ /**
7
+ * `canonical` and `og:url` name the SAME page. The bug this suite locks down is
8
+ * that they were built by two different template literals with opposite slash
9
+ * conventions, so they silently disagreed:
10
+ *
11
+ * canonical: `${siteUrl}/${path}` // path "/skills" -> "site.com//skills"
12
+ * og:url: `${siteUrl}${path}` // path "skills" -> "site.comskills"
13
+ *
14
+ * A consumer that "fixed" one by reshaping `path` at the call site necessarily
15
+ * broke the other. So every case below asserts BOTH tags together — that
16
+ * pairing is the actual invariant, not either URL on its own.
17
+ */
18
+
19
+ const BASE: SiteMetaConfig = {
20
+ name: 'Marketplace',
21
+ description: 'Capability catalogue',
22
+ siteUrl: 'https://marketplace.cmdop.com',
23
+ ogImage: '/opengraph-image',
24
+ ogBackend: '',
25
+ }
26
+
27
+ const LOCALIZED: SiteMetaConfig = {
28
+ ...BASE,
29
+ siteUrl: 'https://cmdop.com',
30
+ locales: ['en', 'de'],
31
+ defaultLocale: 'en',
32
+ }
33
+
34
+ /** Reads back the two URLs the factory is supposed to keep in lock-step. */
35
+ function urlsFor(config: SiteMetaConfig, page: Parameters<typeof createMetadata>[1]) {
36
+ const meta = createMetadata(config, page)
37
+ const alternates = meta.alternates as { canonical?: string; languages?: Record<string, string> }
38
+ const openGraph = meta.openGraph as { url?: string } | undefined
39
+ return { canonical: alternates?.canonical, ogUrl: openGraph?.url, languages: alternates?.languages }
40
+ }
41
+
42
+ /** The single invariant: both tags point at the one expected URL. */
43
+ function assertBoth(
44
+ config: SiteMetaConfig,
45
+ page: Parameters<typeof createMetadata>[1],
46
+ expected: string
47
+ ) {
48
+ const { canonical, ogUrl } = urlsFor(config, page)
49
+ assert.equal(canonical, expected, 'canonical')
50
+ assert.equal(ogUrl, expected, 'og:url')
51
+ assert.equal(canonical, ogUrl, 'canonical and og:url must agree')
52
+ }
53
+
54
+ // ── no locales (marketplace, llm, mark) ──────────────────────────────────────
55
+
56
+ test('no-locale + home (path: "") → bare site URL', () => {
57
+ assertBoth(BASE, { title: 'Home', path: '' }, 'https://marketplace.cmdop.com')
58
+ })
59
+
60
+ test('no-locale + home (path omitted) → bare site URL', () => {
61
+ assertBoth(BASE, { title: 'Home' }, 'https://marketplace.cmdop.com')
62
+ })
63
+
64
+ test('no-locale + home (path: "/") → bare site URL, not a trailing slash', () => {
65
+ // `routes.home` is literally '/' in apps/marketplace, so this is a real input.
66
+ assertBoth(BASE, { title: 'Home', path: '/' }, 'https://marketplace.cmdop.com')
67
+ })
68
+
69
+ test('no-locale + "/skills" → one slash, not two, not zero', () => {
70
+ assertBoth(BASE, { title: 'Skills', path: '/skills' }, 'https://marketplace.cmdop.com/skills')
71
+ })
72
+
73
+ test('no-locale + nested "/skills/foo" → the live bug: og:url glued the domain to the path', () => {
74
+ assertBoth(
75
+ BASE,
76
+ { title: 'Foo', path: '/skills/foo' },
77
+ 'https://marketplace.cmdop.com/skills/foo'
78
+ )
79
+ })
80
+
81
+ // ── locales (web) ────────────────────────────────────────────────────────────
82
+
83
+ test('locale + home → origin + locale only', () => {
84
+ assertBoth(LOCALIZED, { title: 'Home', path: '', locale: 'en' }, 'https://cmdop.com/en')
85
+ })
86
+
87
+ test('locale + "/pricing" → origin + locale + path', () => {
88
+ assertBoth(
89
+ LOCALIZED,
90
+ { title: 'Pricing', path: '/pricing', locale: 'de' },
91
+ 'https://cmdop.com/de/pricing'
92
+ )
93
+ })
94
+
95
+ test('locale + nested path', () => {
96
+ assertBoth(
97
+ LOCALIZED,
98
+ { title: 'Terms', path: '/legal/terms', locale: 'en' },
99
+ 'https://cmdop.com/en/legal/terms'
100
+ )
101
+ })
102
+
103
+ test('locale hreflang alternates are built by the same rule', () => {
104
+ const { languages } = urlsFor(LOCALIZED, { title: 'Pricing', path: '/pricing', locale: 'en' })
105
+ assert.deepEqual(languages, {
106
+ en: 'https://cmdop.com/en/pricing',
107
+ de: 'https://cmdop.com/de/pricing',
108
+ 'x-default': 'https://cmdop.com/en/pricing',
109
+ })
110
+ })
111
+
112
+ test('locale hreflang alternates at home carry no trailing slash', () => {
113
+ const { languages } = urlsFor(LOCALIZED, { title: 'Home', path: '', locale: 'en' })
114
+ assert.deepEqual(languages, {
115
+ en: 'https://cmdop.com/en',
116
+ de: 'https://cmdop.com/de',
117
+ 'x-default': 'https://cmdop.com/en',
118
+ })
119
+ })
120
+
121
+ // ── normalization guards ─────────────────────────────────────────────────────
122
+
123
+ test('a trailing slash on the site URL does not double up', () => {
124
+ assertBoth(
125
+ { ...BASE, siteUrl: 'https://marketplace.cmdop.com/' },
126
+ { title: 'Skills', path: '/skills' },
127
+ 'https://marketplace.cmdop.com/skills'
128
+ )
129
+ })
130
+
131
+ test('an off-contract slash-less path still yields a well-formed URL', () => {
132
+ // The old consumer wrapper stripped the leading slash before calling the
133
+ // factory. That workaround is gone, but a call site that passes 'skills'
134
+ // must still not produce "site.comskills".
135
+ assertBoth(BASE, { title: 'Skills', path: 'skills' }, 'https://marketplace.cmdop.com/skills')
136
+ })
@@ -46,7 +46,11 @@ export interface PageMeta {
46
46
  keywords?: string[]
47
47
  /** Current locale, used for canonical + openGraph.locale + alternates. */
48
48
  locale?: string
49
- /** Path without locale prefix, e.g. "/skills". '' for home. */
49
+ /**
50
+ * Path without locale prefix, e.g. "/skills" or "/skills/foo". Home is ''
51
+ * (or '/'). Leading/trailing slashes are normalized, so both canonical and
52
+ * og:url come out well-formed whichever you pass.
53
+ */
50
54
  path?: string
51
55
  /**
52
56
  * What the OG image should be. Defaults to `'static'` (the brand image).
@@ -60,22 +64,45 @@ export interface PageMeta {
60
64
  extra?: Metadata
61
65
  }
62
66
 
67
+ /**
68
+ * Joins the site origin, an optional locale segment, and a page path into one
69
+ * absolute URL.
70
+ *
71
+ * This is the SINGLE place the URL shape is decided. `canonical` and
72
+ * `openGraph.url` describe the same page, so they must be built by the same
73
+ * function — when they were two hand-rolled template literals they drifted
74
+ * apart (one inserted a slash the other didn't), and a page shipped a canonical
75
+ * and an og:url pointing at two different URLs.
76
+ *
77
+ * Contract (see `PageMeta.path`): `path` carries its own leading slash —
78
+ * "/skills", "/skills/foo" — and home is '' (we also tolerate '/'). Segments
79
+ * are normalized rather than trusted, so a stray or missing slash at a call
80
+ * site cannot produce "site.com//skills" or "site.comskills".
81
+ */
82
+ function buildUrl(siteUrl: string, locale: string | undefined, path: string): string {
83
+ const origin = siteUrl.replace(/\/+$/, '')
84
+ const segments = [locale, path]
85
+ .map((segment) => segment?.replace(/^\/+|\/+$/g, '') ?? '')
86
+ .filter(Boolean)
87
+ return segments.length ? `${origin}/${segments.join('/')}` : origin
88
+ }
89
+
63
90
  function buildAlternates(
64
91
  config: SiteMetaConfig,
65
92
  path: string,
66
93
  locale: string
67
94
  ): Metadata['alternates'] {
68
95
  if (!config.locales?.length) {
69
- return { canonical: `${config.siteUrl}/${path}`.replace(/\/+$/, '') || config.siteUrl }
96
+ return { canonical: buildUrl(config.siteUrl, undefined, path) }
70
97
  }
71
98
  const languages: Record<string, string> = {}
72
99
  for (const loc of config.locales) {
73
- languages[loc] = `${config.siteUrl}/${loc}${path}`
100
+ languages[loc] = buildUrl(config.siteUrl, loc, path)
74
101
  }
75
102
  const xDefault =
76
103
  config.defaultLocale ?? (config.locales.includes('en') ? 'en' : config.locales[0])
77
- languages['x-default'] = `${config.siteUrl}/${xDefault}${path}`
78
- return { canonical: `${config.siteUrl}/${locale}${path}`, languages }
104
+ languages['x-default'] = buildUrl(config.siteUrl, xDefault, path)
105
+ return { canonical: buildUrl(config.siteUrl, locale, path), languages }
79
106
  }
80
107
 
81
108
  /**
@@ -108,9 +135,9 @@ export function createMetadata(config: SiteMetaConfig, page: PageMeta = {}): Met
108
135
  const description = page.description ?? config.description
109
136
  const locale = page.locale ?? config.defaultLocale ?? 'en'
110
137
  const path = page.path ?? ''
111
- const ogUrl = config.locales?.length
112
- ? `${config.siteUrl}/${locale}${path}`
113
- : `${config.siteUrl}${path}`
138
+ // Same builder as the canonical below — og:url and canonical name the same
139
+ // page, so they are never allowed to be assembled by two different rules.
140
+ const ogUrl = buildUrl(config.siteUrl, config.locales?.length ? locale : undefined, path)
114
141
 
115
142
  const ogImage = resolveOgImage(page.og ?? 'static', config.ogImage, title, config.ogBackend)
116
143