@reblu/site-next 1.18.0 → 1.20.0

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/dist/index.d.cts CHANGED
@@ -1,9 +1,11 @@
1
1
  export { NextCacheAdapter } from './cache.cjs';
2
- export { CtaButtons, CtaButtonsProps } from './cta-buttons.cjs';
2
+ import { CtaButtonsProps } from './cta-buttons.cjs';
3
+ export { CtaButtonVariant, CtaButtons } from './cta-buttons.cjs';
3
4
  import * as react_jsx_runtime from 'react/jsx-runtime';
4
5
  import { Cta } from '@reblu/site-contracts';
5
6
  import { RebluClient } from '@reblu/site-client';
6
7
  import { ReactNode } from 'react';
8
+ import '@reblu/site-ui';
7
9
 
8
10
  interface StickyMobileCtaProps {
9
11
  ctas: Cta[];
@@ -16,7 +18,6 @@ interface StickyMobileCtaProps {
16
18
  */
17
19
  declare function StickyMobileCta({ ctas }: StickyMobileCtaProps): react_jsx_runtime.JSX.Element | null;
18
20
 
19
- type ButtonSize = 'sm' | 'default' | 'lg';
20
21
  /** The `Cta.slot` union — the valid placement keys, sourced from the contract. */
21
22
  type CtaSlot = Cta['slot'];
22
23
  /**
@@ -25,14 +26,13 @@ type CtaSlot = Cta['slot'];
25
26
  * narrower singleton, without coupling this component to the whole surface.
26
27
  */
27
28
  type CtaClient = Pick<RebluClient, 'ctas'>;
28
- interface EditableCtaProps {
29
- /** CtaSlot value: hero | blog-end | services | contact | sticky-mobile | footer. */
29
+ /** Presentation props `EditableCta` forwards unchanged to {@link CtaButtons}. */
30
+ type ForwardedButtonProps = Omit<CtaButtonsProps, 'ctas'>;
31
+ interface EditableCtaProps extends ForwardedButtonProps {
32
+ /** CtaSlot value: header | hero | blog-end | services | contact | sticky-mobile | footer. */
30
33
  slot: CtaSlot;
31
34
  /** The Reblu SDK client instance the SITE owns (env-configured upstream). */
32
35
  client: CtaClient;
33
- className?: string;
34
- buttonClassName?: string;
35
- size?: ButtonSize;
36
36
  /**
37
37
  * Rendered when the slot has no configured CTA (or the upstream fetch fails).
38
38
  * Lets each placement keep its existing static CTA as a graceful fallback.
@@ -50,6 +50,6 @@ interface EditableCtaProps {
50
50
  * reads `process.env` and stays framework-glue only — the SITE owns the env and
51
51
  * wires the ISR-aware {@link NextCacheAdapter}.
52
52
  */
53
- declare function EditableCta({ slot, client, className, buttonClassName, size, fallback, }: EditableCtaProps): Promise<react_jsx_runtime.JSX.Element>;
53
+ declare function EditableCta({ slot, client, fallback, ...buttonProps }: EditableCtaProps): Promise<react_jsx_runtime.JSX.Element>;
54
54
 
55
- export { type CtaClient, type CtaSlot, EditableCta, type EditableCtaProps, StickyMobileCta, type StickyMobileCtaProps };
55
+ export { CtaButtonsProps, type CtaClient, type CtaSlot, EditableCta, type EditableCtaProps, StickyMobileCta, type StickyMobileCtaProps };
package/dist/index.d.ts CHANGED
@@ -1,9 +1,11 @@
1
1
  export { NextCacheAdapter } from './cache.js';
2
- export { CtaButtons, CtaButtonsProps } from './cta-buttons.js';
2
+ import { CtaButtonsProps } from './cta-buttons.js';
3
+ export { CtaButtonVariant, CtaButtons } from './cta-buttons.js';
3
4
  import * as react_jsx_runtime from 'react/jsx-runtime';
4
5
  import { Cta } from '@reblu/site-contracts';
5
6
  import { RebluClient } from '@reblu/site-client';
6
7
  import { ReactNode } from 'react';
8
+ import '@reblu/site-ui';
7
9
 
8
10
  interface StickyMobileCtaProps {
9
11
  ctas: Cta[];
@@ -16,7 +18,6 @@ interface StickyMobileCtaProps {
16
18
  */
17
19
  declare function StickyMobileCta({ ctas }: StickyMobileCtaProps): react_jsx_runtime.JSX.Element | null;
18
20
 
19
- type ButtonSize = 'sm' | 'default' | 'lg';
20
21
  /** The `Cta.slot` union — the valid placement keys, sourced from the contract. */
21
22
  type CtaSlot = Cta['slot'];
22
23
  /**
@@ -25,14 +26,13 @@ type CtaSlot = Cta['slot'];
25
26
  * narrower singleton, without coupling this component to the whole surface.
26
27
  */
27
28
  type CtaClient = Pick<RebluClient, 'ctas'>;
28
- interface EditableCtaProps {
29
- /** CtaSlot value: hero | blog-end | services | contact | sticky-mobile | footer. */
29
+ /** Presentation props `EditableCta` forwards unchanged to {@link CtaButtons}. */
30
+ type ForwardedButtonProps = Omit<CtaButtonsProps, 'ctas'>;
31
+ interface EditableCtaProps extends ForwardedButtonProps {
32
+ /** CtaSlot value: header | hero | blog-end | services | contact | sticky-mobile | footer. */
30
33
  slot: CtaSlot;
31
34
  /** The Reblu SDK client instance the SITE owns (env-configured upstream). */
32
35
  client: CtaClient;
33
- className?: string;
34
- buttonClassName?: string;
35
- size?: ButtonSize;
36
36
  /**
37
37
  * Rendered when the slot has no configured CTA (or the upstream fetch fails).
38
38
  * Lets each placement keep its existing static CTA as a graceful fallback.
@@ -50,6 +50,6 @@ interface EditableCtaProps {
50
50
  * reads `process.env` and stays framework-glue only — the SITE owns the env and
51
51
  * wires the ISR-aware {@link NextCacheAdapter}.
52
52
  */
53
- declare function EditableCta({ slot, client, className, buttonClassName, size, fallback, }: EditableCtaProps): Promise<react_jsx_runtime.JSX.Element>;
53
+ declare function EditableCta({ slot, client, fallback, ...buttonProps }: EditableCtaProps): Promise<react_jsx_runtime.JSX.Element>;
54
54
 
55
- export { type CtaClient, type CtaSlot, EditableCta, type EditableCtaProps, StickyMobileCta, type StickyMobileCtaProps };
55
+ export { CtaButtonsProps, type CtaClient, type CtaSlot, EditableCta, type EditableCtaProps, StickyMobileCta, type StickyMobileCtaProps };
package/dist/index.js CHANGED
@@ -19,25 +19,52 @@ var NextCacheAdapter = class {
19
19
  };
20
20
 
21
21
  // src/cta-buttons.tsx
22
- import { Button } from "@reblu/site-ui";
22
+ import { Button, cn } from "@reblu/site-ui";
23
23
  import Link from "next/link";
24
- import { jsx } from "react/jsx-runtime";
24
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
25
+ var DEFAULT_NEW_WINDOW_HINT = "(se abre en una nueva ventana)";
25
26
  function isExternal(href) {
26
27
  return /^https?:\/\//i.test(href);
27
28
  }
28
- function CtaButtons({ ctas, className, buttonClassName, size = "lg" }) {
29
+ function CtaButtons({
30
+ ctas,
31
+ className,
32
+ buttonClassName,
33
+ size = "lg",
34
+ icon,
35
+ primaryVariant = "default",
36
+ secondaryVariant = "default",
37
+ primaryClassName,
38
+ secondaryClassName,
39
+ newWindowHint = DEFAULT_NEW_WINDOW_HINT
40
+ }) {
29
41
  if (ctas.length === 0) return null;
30
- return /* @__PURE__ */ jsx("div", { className, "data-slot": "cta-buttons", children: ctas.map((cta) => /* @__PURE__ */ jsx(
31
- Button,
32
- {
33
- asChild: true,
34
- size,
35
- className: buttonClassName,
36
- "data-cta-id": cta.analyticsId ?? void 0,
37
- children: isExternal(cta.href) ? /* @__PURE__ */ jsx("a", { href: cta.href, target: "_blank", rel: "noopener noreferrer", children: cta.label }) : /* @__PURE__ */ jsx(Link, { href: cta.href, children: cta.label })
38
- },
39
- cta.id
40
- )) });
42
+ const leadingIcon = icon == null ? null : /* @__PURE__ */ jsx("span", { "aria-hidden": "true", className: "inline-flex shrink-0", children: icon });
43
+ return /* @__PURE__ */ jsx("div", { className, "data-slot": "cta-buttons", children: ctas.map((cta, index) => {
44
+ const isPrimary = index === 0;
45
+ return /* @__PURE__ */ jsx(
46
+ Button,
47
+ {
48
+ asChild: true,
49
+ size,
50
+ variant: isPrimary ? primaryVariant : secondaryVariant,
51
+ className: cn(buttonClassName, isPrimary ? primaryClassName : secondaryClassName),
52
+ "data-cta-id": cta.analyticsId ?? void 0,
53
+ children: isExternal(cta.href) ? /* @__PURE__ */ jsxs("a", { href: cta.href, target: "_blank", rel: "noopener noreferrer", children: [
54
+ leadingIcon,
55
+ cta.label,
56
+ newWindowHint ? /* @__PURE__ */ jsxs(Fragment, { children: [
57
+ " ",
58
+ /* @__PURE__ */ jsx("span", { className: "sr-only", children: newWindowHint })
59
+ ] }) : null
60
+ ] }) : /* @__PURE__ */ jsxs(Link, { href: cta.href, children: [
61
+ leadingIcon,
62
+ cta.label
63
+ ] })
64
+ },
65
+ cta.id
66
+ );
67
+ }) });
41
68
  }
42
69
 
43
70
  // src/sticky-mobile-cta.tsx
@@ -56,18 +83,11 @@ function StickyMobileCta({ ctas }) {
56
83
  }
57
84
 
58
85
  // src/editable-cta.tsx
59
- import { Fragment, jsx as jsx3 } from "react/jsx-runtime";
60
- async function EditableCta({
61
- slot,
62
- client,
63
- className,
64
- buttonClassName,
65
- size,
66
- fallback = null
67
- }) {
86
+ import { Fragment as Fragment2, jsx as jsx3 } from "react/jsx-runtime";
87
+ async function EditableCta({ slot, client, fallback = null, ...buttonProps }) {
68
88
  const { data: ctas } = await client.ctas.list(slot).catch(() => ({ data: [] }));
69
- if (ctas.length === 0) return /* @__PURE__ */ jsx3(Fragment, { children: fallback });
70
- return /* @__PURE__ */ jsx3(CtaButtons, { ctas, className, buttonClassName, size });
89
+ if (ctas.length === 0) return /* @__PURE__ */ jsx3(Fragment2, { children: fallback });
90
+ return /* @__PURE__ */ jsx3(CtaButtons, { ctas, ...buttonProps });
71
91
  }
72
92
  export {
73
93
  CtaButtons,
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/next-cache-adapter.ts","../src/cta-buttons.tsx","../src/sticky-mobile-cta.tsx","../src/editable-cta.tsx"],"sourcesContent":["import type { RebluCacheAdapter, RebluCachePolicy } from '@reblu/site-client'\n\n/**\n * Next.js ISR cache adapter (REB-418 / design doc §5.2).\n *\n * Implements the framework-agnostic {@link RebluCacheAdapter} interface exported\n * by `@reblu/site-client`, translating a {@link RebluCachePolicy} into the\n * `fetch` options Next understands. This is the piece that restores the exact\n * ISR behavior demo-site hard-codes today — now injected instead of baked in:\n *\n * ```ts\n * createRebluClient({ baseUrl, apiKey, cache: new NextCacheAdapter(60) })\n * ```\n *\n * - `revalidate` > 0 → `{ next: { revalidate } }` (time-based ISR)\n * - `revalidate` `false` / `0` → `{ cache: 'no-store' }` (dynamic / preview)\n * - omitted `revalidate` → the constructor `defaultRevalidate`\n */\nexport class NextCacheAdapter implements RebluCacheAdapter {\n /**\n * @param defaultRevalidate ISR window (seconds) applied when a request omits\n * its own `revalidate`. Defaults to `60` for parity with demo-site's current\n * ISR default (`client.ts:45`). Pass `false`/`0` to default to no-store.\n */\n constructor(private readonly defaultRevalidate: number | false = 60) {}\n\n resolveRequestInit(policy: RebluCachePolicy): RequestInit {\n const revalidate = policy.revalidate ?? this.defaultRevalidate\n if (revalidate === false || revalidate === 0) {\n return { cache: 'no-store' }\n }\n return { next: { revalidate } } as RequestInit\n }\n}\n","'use client'\n\nimport type { Cta } from '@reblu/site-contracts'\nimport { Button } from '@reblu/site-ui'\nimport Link from 'next/link'\n\ntype ButtonSize = 'sm' | 'default' | 'lg'\n\nexport interface CtaButtonsProps {\n ctas: Cta[]\n /** Wrapper class (layout of the button group). */\n className?: string\n /** Class applied to each button. */\n buttonClassName?: string\n size?: ButtonSize\n}\n\n/** External = absolute http(s) URL (e.g. a Klinikare booking link). */\nfunction isExternal(href: string): boolean {\n return /^https?:\\/\\//i.test(href)\n}\n\n/**\n * Presentational CTA renderer (REB-394, moved to `@reblu/site-next` in REB-418).\n * Pure — takes already-resolved CTAs and renders one styled button-link each.\n * Internal hrefs use `next/link` (client routing); external URLs open in a new,\n * safely-rel tab. `analyticsId` is surfaced as `data-cta-id` on the clickable\n * element for GA `cta_click` tracking. Renders nothing when there are no CTAs.\n *\n * Imports `Button` from `@reblu/site-ui` (public) — never `@reblu/ui` (private).\n */\nexport function CtaButtons({ ctas, className, buttonClassName, size = 'lg' }: CtaButtonsProps) {\n if (ctas.length === 0) return null\n\n return (\n <div className={className} data-slot='cta-buttons'>\n {ctas.map((cta) => (\n <Button\n key={cta.id}\n asChild\n size={size}\n className={buttonClassName}\n data-cta-id={cta.analyticsId ?? undefined}\n >\n {isExternal(cta.href) ? (\n <a href={cta.href} target='_blank' rel='noopener noreferrer'>\n {cta.label}\n </a>\n ) : (\n <Link href={cta.href}>{cta.label}</Link>\n )}\n </Button>\n ))}\n </div>\n )\n}\n","import type { Cta } from '@reblu/site-contracts'\n\nimport { CtaButtons } from './cta-buttons'\n\nexport interface StickyMobileCtaProps {\n ctas: Cta[]\n}\n\n/**\n * StickyMobileCta (REB-394, moved to `@reblu/site-next` in REB-418) — a\n * mobile-only sticky bottom bar surfacing the `sticky-mobile` slot CTAs. Hidden\n * on `md+` (desktop has its own CTAs). Renders nothing when no CTA is configured,\n * so it never adds empty chrome.\n */\nexport function StickyMobileCta({ ctas }: StickyMobileCtaProps) {\n if (ctas.length === 0) return null\n\n return (\n <div className='fixed inset-x-0 bottom-0 z-50 md:hidden border-t border-border bg-background/95 px-4 py-3 backdrop-blur supports-[backdrop-filter]:bg-background/80'>\n <CtaButtons\n ctas={ctas}\n className='flex justify-center gap-3'\n buttonClassName='w-full rounded-lg font-semibold'\n size='lg'\n />\n </div>\n )\n}\n","import type { RebluClient } from '@reblu/site-client'\nimport type { Cta } from '@reblu/site-contracts'\nimport type { ReactNode } from 'react'\n\nimport { CtaButtons } from './cta-buttons'\n\ntype ButtonSize = 'sm' | 'default' | 'lg'\n\n/** The `Cta.slot` union — the valid placement keys, sourced from the contract. */\nexport type CtaSlot = Cta['slot']\n\n/**\n * The slice of the SDK client `EditableCta` needs: just the `ctas` namespace.\n * Typing it structurally lets a SITE pass either the full `RebluClient` or a\n * narrower singleton, without coupling this component to the whole surface.\n */\nexport type CtaClient = Pick<RebluClient, 'ctas'>\n\nexport interface EditableCtaProps {\n /** CtaSlot value: hero | blog-end | services | contact | sticky-mobile | footer. */\n slot: CtaSlot\n /** The Reblu SDK client instance the SITE owns (env-configured upstream). */\n client: CtaClient\n className?: string\n buttonClassName?: string\n size?: ButtonSize\n /**\n * Rendered when the slot has no configured CTA (or the upstream fetch fails).\n * Lets each placement keep its existing static CTA as a graceful fallback.\n */\n fallback?: ReactNode\n}\n\n/**\n * EditableCta (REB-394, moved to `@reblu/site-next` in REB-418) — drop-anywhere\n * async Server Component that renders the CTAs configured for a slot from nexus,\n * with no redeploy. Fetches active CTAs via the injected SDK client and degrades\n * gracefully: on empty/failure it renders `fallback` (a static CTA) instead of\n * breaking the page.\n *\n * The client is injected (prop or per-SITE singleton) so this package never\n * reads `process.env` and stays framework-glue only — the SITE owns the env and\n * wires the ISR-aware {@link NextCacheAdapter}.\n */\nexport async function EditableCta({\n slot,\n client,\n className,\n buttonClassName,\n size,\n fallback = null,\n}: EditableCtaProps) {\n const { data: ctas } = await client.ctas\n .list(slot)\n .catch(() => ({ data: [] as Cta[] }))\n\n if (ctas.length === 0) return <>{fallback}</>\n\n return (\n <CtaButtons ctas={ctas} className={className} buttonClassName={buttonClassName} size={size} />\n )\n}\n"],"mappings":";AAkBO,IAAM,mBAAN,MAAoD;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAMzD,YAA6B,oBAAoC,IAAI;AAAxC;AAAA,EAAyC;AAAA,EAAzC;AAAA,EAE7B,mBAAmB,QAAuC;AACxD,UAAM,aAAa,OAAO,cAAc,KAAK;AAC7C,QAAI,eAAe,SAAS,eAAe,GAAG;AAC5C,aAAO,EAAE,OAAO,WAAW;AAAA,IAC7B;AACA,WAAO,EAAE,MAAM,EAAE,WAAW,EAAE;AAAA,EAChC;AACF;;;AC9BA,SAAS,cAAc;AACvB,OAAO,UAAU;AAyCL;AA3BZ,SAAS,WAAW,MAAuB;AACzC,SAAO,gBAAgB,KAAK,IAAI;AAClC;AAWO,SAAS,WAAW,EAAE,MAAM,WAAW,iBAAiB,OAAO,KAAK,GAAoB;AAC7F,MAAI,KAAK,WAAW,EAAG,QAAO;AAE9B,SACE,oBAAC,SAAI,WAAsB,aAAU,eAClC,eAAK,IAAI,CAAC,QACT;AAAA,IAAC;AAAA;AAAA,MAEC,SAAO;AAAA,MACP;AAAA,MACA,WAAW;AAAA,MACX,eAAa,IAAI,eAAe;AAAA,MAE/B,qBAAW,IAAI,IAAI,IAClB,oBAAC,OAAE,MAAM,IAAI,MAAM,QAAO,UAAS,KAAI,uBACpC,cAAI,OACP,IAEA,oBAAC,QAAK,MAAM,IAAI,MAAO,cAAI,OAAM;AAAA;AAAA,IAX9B,IAAI;AAAA,EAaX,CACD,GACH;AAEJ;;;ACpCM,gBAAAA,YAAA;AALC,SAAS,gBAAgB,EAAE,KAAK,GAAyB;AAC9D,MAAI,KAAK,WAAW,EAAG,QAAO;AAE9B,SACE,gBAAAA,KAAC,SAAI,WAAU,uJACb,0BAAAA;AAAA,IAAC;AAAA;AAAA,MACC;AAAA,MACA,WAAU;AAAA,MACV,iBAAgB;AAAA,MAChB,MAAK;AAAA;AAAA,EACP,GACF;AAEJ;;;AC6BgC,0BAAAC,YAAA;AAZhC,eAAsB,YAAY;AAAA,EAChC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,WAAW;AACb,GAAqB;AACnB,QAAM,EAAE,MAAM,KAAK,IAAI,MAAM,OAAO,KACjC,KAAK,IAAI,EACT,MAAM,OAAO,EAAE,MAAM,CAAC,EAAW,EAAE;AAEtC,MAAI,KAAK,WAAW,EAAG,QAAO,gBAAAA,KAAA,YAAG,oBAAS;AAE1C,SACE,gBAAAA,KAAC,cAAW,MAAY,WAAsB,iBAAkC,MAAY;AAEhG;","names":["jsx","jsx"]}
1
+ {"version":3,"sources":["../src/next-cache-adapter.ts","../src/cta-buttons.tsx","../src/sticky-mobile-cta.tsx","../src/editable-cta.tsx"],"sourcesContent":["import type { RebluCacheAdapter, RebluCachePolicy } from '@reblu/site-client'\n\n/**\n * Next.js ISR cache adapter (REB-418 / design doc §5.2).\n *\n * Implements the framework-agnostic {@link RebluCacheAdapter} interface exported\n * by `@reblu/site-client`, translating a {@link RebluCachePolicy} into the\n * `fetch` options Next understands. This is the piece that restores the exact\n * ISR behavior demo-site hard-codes today — now injected instead of baked in:\n *\n * ```ts\n * createRebluClient({ baseUrl, apiKey, cache: new NextCacheAdapter(60) })\n * ```\n *\n * - `revalidate` > 0 → `{ next: { revalidate } }` (time-based ISR)\n * - `revalidate` `false` / `0` → `{ cache: 'no-store' }` (dynamic / preview)\n * - omitted `revalidate` → the constructor `defaultRevalidate`\n */\nexport class NextCacheAdapter implements RebluCacheAdapter {\n /**\n * @param defaultRevalidate ISR window (seconds) applied when a request omits\n * its own `revalidate`. Defaults to `60` for parity with demo-site's current\n * ISR default (`client.ts:45`). Pass `false`/`0` to default to no-store.\n */\n constructor(private readonly defaultRevalidate: number | false = 60) {}\n\n resolveRequestInit(policy: RebluCachePolicy): RequestInit {\n const revalidate = policy.revalidate ?? this.defaultRevalidate\n if (revalidate === false || revalidate === 0) {\n return { cache: 'no-store' }\n }\n return { next: { revalidate } } as RequestInit\n }\n}\n","'use client'\n\nimport type { Cta } from '@reblu/site-contracts'\nimport { Button, cn, type ButtonVariantProps } from '@reblu/site-ui'\nimport Link from 'next/link'\nimport type { ReactNode } from 'react'\n\ntype ButtonSize = 'sm' | 'default' | 'lg'\n\n/** A `@reblu/site-ui` Button variant (`default`, `outline`, `secondary`, …). */\nexport type CtaButtonVariant = NonNullable<ButtonVariantProps['variant']>\n\n/** Default screen-reader hint appended to CTAs that open in a new tab. */\nconst DEFAULT_NEW_WINDOW_HINT = '(se abre en una nueva ventana)'\n\nexport interface CtaButtonsProps {\n ctas: Cta[]\n /** Wrapper class (layout of the button group). */\n className?: string\n /** Class applied to each button. */\n buttonClassName?: string\n size?: ButtonSize\n /**\n * Leading icon rendered before every CTA label. Decorative: it is wrapped in\n * an `aria-hidden` span, so the accessible name stays the label.\n */\n icon?: ReactNode\n /** Button variant of the primary CTA (the first one, by `sortOrder`). Default `default`. */\n primaryVariant?: CtaButtonVariant\n /** Button variant of every secondary CTA (all but the first). Default `default`. */\n secondaryVariant?: CtaButtonVariant\n /**\n * Extra class for the primary CTA, merged after `buttonClassName`. Lets a SITE\n * map the primary CTA to its own button style (e.g. a brand `cta` variant).\n */\n primaryClassName?: string\n /** Extra class for every secondary CTA, merged after `buttonClassName`. */\n secondaryClassName?: string\n /**\n * Visually-hidden text appended to CTAs that open in a new tab, so screen\n * readers announce the context change. Default «(se abre en una nueva ventana)».\n */\n newWindowHint?: string\n}\n\n/** External = absolute http(s) URL (e.g. a third-party booking link). */\nfunction isExternal(href: string): boolean {\n return /^https?:\\/\\//i.test(href)\n}\n\n/**\n * Presentational CTA renderer (REB-394, moved to `@reblu/site-next` in REB-418).\n * Pure — takes already-resolved CTAs and renders one styled button-link each.\n * Internal hrefs use `next/link` (client routing); external URLs open in a new,\n * safely-rel tab and carry a visually-hidden new-window hint. `analyticsId` is\n * surfaced as `data-cta-id` on the clickable element for GA `cta_click`\n * tracking. Renders nothing when there are no CTAs.\n *\n * The first CTA is the primary one; the rest are secondary. Each role takes its\n * own Button variant and extra class, so a SITE maps the CTAs to its own button\n * styles without forking this markup.\n *\n * Imports `Button` from `@reblu/site-ui` (public) — never `@reblu/ui` (private).\n */\nexport function CtaButtons({\n ctas,\n className,\n buttonClassName,\n size = 'lg',\n icon,\n primaryVariant = 'default',\n secondaryVariant = 'default',\n primaryClassName,\n secondaryClassName,\n newWindowHint = DEFAULT_NEW_WINDOW_HINT,\n}: CtaButtonsProps) {\n if (ctas.length === 0) return null\n\n const leadingIcon =\n icon == null ? null : (\n <span aria-hidden='true' className='inline-flex shrink-0'>\n {icon}\n </span>\n )\n\n return (\n <div className={className} data-slot='cta-buttons'>\n {ctas.map((cta, index) => {\n const isPrimary = index === 0\n return (\n <Button\n key={cta.id}\n asChild\n size={size}\n variant={isPrimary ? primaryVariant : secondaryVariant}\n className={cn(buttonClassName, isPrimary ? primaryClassName : secondaryClassName)}\n data-cta-id={cta.analyticsId ?? undefined}\n >\n {isExternal(cta.href) ? (\n <a href={cta.href} target='_blank' rel='noopener noreferrer'>\n {leadingIcon}\n {cta.label}\n {newWindowHint ? <> <span className='sr-only'>{newWindowHint}</span></> : null}\n </a>\n ) : (\n <Link href={cta.href}>\n {leadingIcon}\n {cta.label}\n </Link>\n )}\n </Button>\n )\n })}\n </div>\n )\n}\n","import type { Cta } from '@reblu/site-contracts'\n\nimport { CtaButtons } from './cta-buttons'\n\nexport interface StickyMobileCtaProps {\n ctas: Cta[]\n}\n\n/**\n * StickyMobileCta (REB-394, moved to `@reblu/site-next` in REB-418) — a\n * mobile-only sticky bottom bar surfacing the `sticky-mobile` slot CTAs. Hidden\n * on `md+` (desktop has its own CTAs). Renders nothing when no CTA is configured,\n * so it never adds empty chrome.\n */\nexport function StickyMobileCta({ ctas }: StickyMobileCtaProps) {\n if (ctas.length === 0) return null\n\n return (\n <div className='fixed inset-x-0 bottom-0 z-50 md:hidden border-t border-border bg-background/95 px-4 py-3 backdrop-blur supports-[backdrop-filter]:bg-background/80'>\n <CtaButtons\n ctas={ctas}\n className='flex justify-center gap-3'\n buttonClassName='w-full rounded-lg font-semibold'\n size='lg'\n />\n </div>\n )\n}\n","import type { RebluClient } from '@reblu/site-client'\nimport type { Cta } from '@reblu/site-contracts'\nimport type { ReactNode } from 'react'\n\nimport { CtaButtons, type CtaButtonsProps } from './cta-buttons'\n\n/** The `Cta.slot` union — the valid placement keys, sourced from the contract. */\nexport type CtaSlot = Cta['slot']\n\n/**\n * The slice of the SDK client `EditableCta` needs: just the `ctas` namespace.\n * Typing it structurally lets a SITE pass either the full `RebluClient` or a\n * narrower singleton, without coupling this component to the whole surface.\n */\nexport type CtaClient = Pick<RebluClient, 'ctas'>\n\n/** Presentation props `EditableCta` forwards unchanged to {@link CtaButtons}. */\ntype ForwardedButtonProps = Omit<CtaButtonsProps, 'ctas'>\n\nexport interface EditableCtaProps extends ForwardedButtonProps {\n /** CtaSlot value: header | hero | blog-end | services | contact | sticky-mobile | footer. */\n slot: CtaSlot\n /** The Reblu SDK client instance the SITE owns (env-configured upstream). */\n client: CtaClient\n /**\n * Rendered when the slot has no configured CTA (or the upstream fetch fails).\n * Lets each placement keep its existing static CTA as a graceful fallback.\n */\n fallback?: ReactNode\n}\n\n/**\n * EditableCta (REB-394, moved to `@reblu/site-next` in REB-418) — drop-anywhere\n * async Server Component that renders the CTAs configured for a slot from nexus,\n * with no redeploy. Fetches active CTAs via the injected SDK client and degrades\n * gracefully: on empty/failure it renders `fallback` (a static CTA) instead of\n * breaking the page.\n *\n * The client is injected (prop or per-SITE singleton) so this package never\n * reads `process.env` and stays framework-glue only — the SITE owns the env and\n * wires the ISR-aware {@link NextCacheAdapter}.\n */\nexport async function EditableCta({ slot, client, fallback = null, ...buttonProps }: EditableCtaProps) {\n const { data: ctas } = await client.ctas\n .list(slot)\n .catch(() => ({ data: [] as Cta[] }))\n\n if (ctas.length === 0) return <>{fallback}</>\n\n return <CtaButtons ctas={ctas} {...buttonProps} />\n}\n"],"mappings":";AAkBO,IAAM,mBAAN,MAAoD;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAMzD,YAA6B,oBAAoC,IAAI;AAAxC;AAAA,EAAyC;AAAA,EAAzC;AAAA,EAE7B,mBAAmB,QAAuC;AACxD,UAAM,aAAa,OAAO,cAAc,KAAK;AAC7C,QAAI,eAAe,SAAS,eAAe,GAAG;AAC5C,aAAO,EAAE,OAAO,WAAW;AAAA,IAC7B;AACA,WAAO,EAAE,MAAM,EAAE,WAAW,EAAE;AAAA,EAChC;AACF;;;AC9BA,SAAS,QAAQ,UAAmC;AACpD,OAAO,UAAU;AA4EX,SAsB2B,UAtB3B,KAsB2B,YAtB3B;AAnEN,IAAM,0BAA0B;AAiChC,SAAS,WAAW,MAAuB;AACzC,SAAO,gBAAgB,KAAK,IAAI;AAClC;AAgBO,SAAS,WAAW;AAAA,EACzB;AAAA,EACA;AAAA,EACA;AAAA,EACA,OAAO;AAAA,EACP;AAAA,EACA,iBAAiB;AAAA,EACjB,mBAAmB;AAAA,EACnB;AAAA,EACA;AAAA,EACA,gBAAgB;AAClB,GAAoB;AAClB,MAAI,KAAK,WAAW,EAAG,QAAO;AAE9B,QAAM,cACJ,QAAQ,OAAO,OACb,oBAAC,UAAK,eAAY,QAAO,WAAU,wBAChC,gBACH;AAGJ,SACE,oBAAC,SAAI,WAAsB,aAAU,eAClC,eAAK,IAAI,CAAC,KAAK,UAAU;AACxB,UAAM,YAAY,UAAU;AAC5B,WACE;AAAA,MAAC;AAAA;AAAA,QAEC,SAAO;AAAA,QACP;AAAA,QACA,SAAS,YAAY,iBAAiB;AAAA,QACtC,WAAW,GAAG,iBAAiB,YAAY,mBAAmB,kBAAkB;AAAA,QAChF,eAAa,IAAI,eAAe;AAAA,QAE/B,qBAAW,IAAI,IAAI,IAClB,qBAAC,OAAE,MAAM,IAAI,MAAM,QAAO,UAAS,KAAI,uBACpC;AAAA;AAAA,UACA,IAAI;AAAA,UACJ,gBAAgB,iCAAE;AAAA;AAAA,YAAC,oBAAC,UAAK,WAAU,WAAW,yBAAc;AAAA,aAAO,IAAM;AAAA,WAC5E,IAEA,qBAAC,QAAK,MAAM,IAAI,MACb;AAAA;AAAA,UACA,IAAI;AAAA,WACP;AAAA;AAAA,MAjBG,IAAI;AAAA,IAmBX;AAAA,EAEJ,CAAC,GACH;AAEJ;;;AChGM,gBAAAA,YAAA;AALC,SAAS,gBAAgB,EAAE,KAAK,GAAyB;AAC9D,MAAI,KAAK,WAAW,EAAG,QAAO;AAE9B,SACE,gBAAAA,KAAC,SAAI,WAAU,uJACb,0BAAAA;AAAA,IAAC;AAAA;AAAA,MACC;AAAA,MACA,WAAU;AAAA,MACV,iBAAgB;AAAA,MAChB,MAAK;AAAA;AAAA,EACP,GACF;AAEJ;;;ACoBgC,qBAAAC,WAAA,OAAAC,YAAA;AALhC,eAAsB,YAAY,EAAE,MAAM,QAAQ,WAAW,MAAM,GAAG,YAAY,GAAqB;AACrG,QAAM,EAAE,MAAM,KAAK,IAAI,MAAM,OAAO,KACjC,KAAK,IAAI,EACT,MAAM,OAAO,EAAE,MAAM,CAAC,EAAW,EAAE;AAEtC,MAAI,KAAK,WAAW,EAAG,QAAO,gBAAAA,KAAAD,WAAA,EAAG,oBAAS;AAE1C,SAAO,gBAAAC,KAAC,cAAW,MAAa,GAAG,aAAa;AAClD;","names":["jsx","Fragment","jsx"]}
package/dist/seo.cjs CHANGED
@@ -26,8 +26,12 @@ __export(seo_exports, {
26
26
  SEO_TITLE_RECOMMENDED_MAX_LENGTH: () => SEO_TITLE_RECOMMENDED_MAX_LENGTH,
27
27
  SEO_TITLE_TEMPLATE_MAX_LENGTH: () => SEO_TITLE_TEMPLATE_MAX_LENGTH,
28
28
  buildMetadata: () => buildMetadata,
29
+ normalizeCanonicalUrl: () => normalizeCanonicalUrl,
29
30
  resolveSeoTitle: () => resolveSeoTitle,
30
31
  resolveSeoTitleTemplate: () => resolveSeoTitleTemplate,
32
+ resolveSiteSeoDescription: () => resolveSiteSeoDescription,
33
+ resolveSiteSeoTitle: () => resolveSiteSeoTitle,
34
+ validateCanonicalUrl: () => validateCanonicalUrl,
31
35
  validateSeoTitleTemplate: () => validateSeoTitleTemplate
32
36
  });
33
37
  module.exports = __toCommonJS(seo_exports);
@@ -75,7 +79,34 @@ function resolveSeoTitle(input, context) {
75
79
  const brand = (part) => part.split(SEO_SITE_NAME_PLACEHOLDER).join(siteName);
76
80
  return `${brand(before)}${display}${brand(after)}`.trim();
77
81
  }
82
+ function validateCanonicalUrl(value) {
83
+ const trimmed = value.trim();
84
+ let url;
85
+ try {
86
+ url = new URL(trimmed);
87
+ } catch {
88
+ return "invalid";
89
+ }
90
+ if (url.protocol !== "https:") return "not_https";
91
+ if (url.username || url.password || url.search || url.hash || /[?#]/.test(trimmed)) {
92
+ return "not_origin";
93
+ }
94
+ if (url.pathname.replace(/\/+$/, "") !== "") return "not_origin";
95
+ return null;
96
+ }
97
+ function normalizeCanonicalUrl(value) {
98
+ const trimmed = value?.trim();
99
+ if (!trimmed || validateCanonicalUrl(trimmed) !== null) return null;
100
+ return new URL(trimmed).origin;
101
+ }
102
+ function resolveSiteSeoTitle(config) {
103
+ return config.seoTitle?.trim() || config.siteName?.trim() || "";
104
+ }
105
+ function resolveSiteSeoDescription(config) {
106
+ return config.seoDescription?.trim() || config.siteDescription?.trim() || "";
107
+ }
78
108
  function buildMetadata(input) {
109
+ if (input.home) return buildSiteMetadata(input);
79
110
  const title = input.seo?.title?.trim() || resolveSeoTitle(input, {
80
111
  template: input.config.seoTitleTemplate,
81
112
  siteName: input.config.siteName
@@ -86,6 +117,17 @@ function buildMetadata(input) {
86
117
  if (description) metadata.description = description;
87
118
  return metadata;
88
119
  }
120
+ function buildSiteMetadata(input) {
121
+ const metadata = {};
122
+ const canonicalUrl = normalizeCanonicalUrl(input.config.canonicalUrl);
123
+ if (canonicalUrl) metadata.metadataBase = new URL(canonicalUrl);
124
+ const title = resolveSiteSeoTitle(input.config);
125
+ if (title) metadata.title = { absolute: title };
126
+ const description = input.description?.trim() || resolveSiteSeoDescription(input.config);
127
+ if (description) metadata.description = description;
128
+ if (canonicalUrl) metadata.alternates = { canonical: "/" };
129
+ return metadata;
130
+ }
89
131
  // Annotate the CommonJS export names for ESM import in node:
90
132
  0 && (module.exports = {
91
133
  DEFAULT_SEO_TITLE_TEMPLATE,
@@ -94,8 +136,12 @@ function buildMetadata(input) {
94
136
  SEO_TITLE_RECOMMENDED_MAX_LENGTH,
95
137
  SEO_TITLE_TEMPLATE_MAX_LENGTH,
96
138
  buildMetadata,
139
+ normalizeCanonicalUrl,
97
140
  resolveSeoTitle,
98
141
  resolveSeoTitleTemplate,
142
+ resolveSiteSeoDescription,
143
+ resolveSiteSeoTitle,
144
+ validateCanonicalUrl,
99
145
  validateSeoTitleTemplate
100
146
  });
101
147
  //# sourceMappingURL=seo.cjs.map
package/dist/seo.cjs.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/seo.ts"],"sourcesContent":["/**\n * `@reblu/site-next/seo` — the ONE page-title rule (REB-871), shared by the\n * Reblu SITE API (which serves `seo.title` already resolved on every detail\n * endpoint) and by SITEs (index/list routes, or an older API without `seo`).\n *\n * The rule, in order:\n * 1. An entity SEO title (`seoTitle` / `metaTitle`) that is non-empty after\n * trimming is the WHOLE title — used as-is, never suffixed.\n * 2. Otherwise the tenant template (`SiteSettings.seoTitleTemplate`, default\n * `%s | {siteName}`) is applied ONCE to the display title — the entity\n * title on a detail route, the section label on a list/index route.\n * 3. The brand is never duplicated: a display title that already names the\n * site is used alone, and a site with no name drops the brand part.\n *\n * Own tsup entry with ZERO runtime dependencies (no React, no `next` — only\n * the `Metadata` TYPE) so base-api can import it server-side and a SITE can\n * call it from `generateMetadata` with no RSC-boundary cost. It must never\n * carry `'use client'`.\n */\nimport type { Metadata } from 'next'\n\n/** Applied when the tenant never chose a template (`seoTitleTemplate` NULL). */\nexport const DEFAULT_SEO_TITLE_TEMPLATE = '%s | {siteName}'\n/** The display-title placeholder — must appear exactly once in a template. */\nexport const SEO_TITLE_PLACEHOLDER = '%s'\n/** Optional brand placeholder, replaced by `siteName`. */\nexport const SEO_SITE_NAME_PLACEHOLDER = '{siteName}'\n/** Upper bound for a stored template (write-side validation). */\nexport const SEO_TITLE_TEMPLATE_MAX_LENGTH = 60\n/** Recommended length of a full page title in search results. */\nexport const SEO_TITLE_RECOMMENDED_MAX_LENGTH = 60\n\nexport type SeoTitleTemplateIssue = 'missing_placeholder' | 'multiple_placeholders' | 'too_long'\n\n/** `null` when `template` is a valid title template, otherwise why it is not. */\nexport function validateSeoTitleTemplate(template: string): SeoTitleTemplateIssue | null {\n const occurrences = template.split(SEO_TITLE_PLACEHOLDER).length - 1\n if (occurrences === 0) return 'missing_placeholder'\n if (occurrences > 1) return 'multiple_placeholders'\n if (template.length > SEO_TITLE_TEMPLATE_MAX_LENGTH) return 'too_long'\n return null\n}\n\n/**\n * The template to apply: the stored one (trimmed) when valid, otherwise the\n * default. An invalid stored value (hand-edited row) degrades to the default\n * instead of producing a broken title.\n */\nexport function resolveSeoTitleTemplate(template: string | null | undefined): string {\n const trimmed = template?.trim() ?? ''\n if (trimmed && validateSeoTitleTemplate(trimmed) === null) return trimmed\n return DEFAULT_SEO_TITLE_TEMPLATE\n}\n\nexport interface SeoTitleInput {\n /** Entity SEO title (`seoTitle` on pages, `metaTitle` on posts/products/courses). */\n seoTitle?: string | null\n /** Display title of the entity (detail routes). */\n title?: string | null\n /** Label of a list/index route (e.g. \"Blog\"), used when there is no entity title. */\n sectionLabel?: string | null\n}\n\nexport interface SeoTitleContext {\n /** `Config.seoTitleTemplate` — null/undefined/invalid → the default. */\n template?: string | null\n /** `Config.siteName`. */\n siteName?: string | null\n}\n\n/** A letter, digit or combining mark — accents included (Unicode-aware). */\nconst WORD_CHAR_AT_END = /[\\p{L}\\p{N}\\p{M}]$/u\nconst WORD_CHAR_AT_START = /^[\\p{L}\\p{N}\\p{M}]/u\n\n/**\n * Whether `phrase` occurs in `text` as a whole word/phrase (case-insensitive):\n * the brand \"Arte\" is in \"Galería Arte\" but not in \"Cartera\" or \"Artesanía\".\n */\nfunction containsPhrase(text: string, phrase: string): boolean {\n const haystack = text.toLocaleLowerCase()\n const needle = phrase.toLocaleLowerCase()\n for (let i = haystack.indexOf(needle); i !== -1; i = haystack.indexOf(needle, i + 1)) {\n const before = haystack.slice(0, i)\n const after = haystack.slice(i + needle.length)\n if (!WORD_CHAR_AT_END.test(before) && !WORD_CHAR_AT_START.test(after)) return true\n }\n return false\n}\n\nexport function resolveSeoTitle(input: SeoTitleInput, context: SeoTitleContext): string {\n const asIs = input.seoTitle?.trim()\n if (asIs) return asIs\n\n const siteName = context.siteName?.trim() ?? ''\n const display = input.title?.trim() || input.sectionLabel?.trim() || ''\n if (!display) return siteName\n\n const template = resolveSeoTitleTemplate(context.template)\n if (template.includes(SEO_SITE_NAME_PLACEHOLDER)) {\n if (!siteName) return display\n if (containsPhrase(display, siteName)) return display\n }\n\n // Split on the placeholder BEFORE inserting the display title, so the title\n // itself is never scanned for placeholders (applied exactly once) and no\n // `String.replace` special pattern (`$&`, `$1`) can reach it.\n const [before = '', after = ''] = template.split(SEO_TITLE_PLACEHOLDER)\n const brand = (part: string) => part.split(SEO_SITE_NAME_PLACEHOLDER).join(siteName)\n return `${brand(before)}${display}${brand(after)}`.trim()\n}\n\nexport interface BuildMetadataInput extends SeoTitleInput {\n /** The detail endpoint's `seo` block — its `title` is already resolved. */\n seo?: { title?: string | null } | null\n /** `GET /api/site/config` (or the subset needed here). */\n config: { siteName?: string | null; seoTitleTemplate?: string | null }\n description?: string | null\n}\n\n/**\n * Next `Metadata` for a SITE route. The title is emitted as `absolute` so a\n * root-layout `title.template` can never append the brand a second time.\n * Description/Open Graph are deliberately minimal here (REB-823 extends them).\n */\nexport function buildMetadata(input: BuildMetadataInput): Metadata {\n const title =\n input.seo?.title?.trim() ||\n resolveSeoTitle(input, {\n template: input.config.seoTitleTemplate,\n siteName: input.config.siteName,\n })\n\n const metadata: Metadata = {}\n if (title) metadata.title = { absolute: title }\n const description = input.description?.trim()\n if (description) metadata.description = description\n return metadata\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAsBO,IAAM,6BAA6B;AAEnC,IAAM,wBAAwB;AAE9B,IAAM,4BAA4B;AAElC,IAAM,gCAAgC;AAEtC,IAAM,mCAAmC;AAKzC,SAAS,yBAAyB,UAAgD;AACvF,QAAM,cAAc,SAAS,MAAM,qBAAqB,EAAE,SAAS;AACnE,MAAI,gBAAgB,EAAG,QAAO;AAC9B,MAAI,cAAc,EAAG,QAAO;AAC5B,MAAI,SAAS,SAAS,8BAA+B,QAAO;AAC5D,SAAO;AACT;AAOO,SAAS,wBAAwB,UAA6C;AACnF,QAAM,UAAU,UAAU,KAAK,KAAK;AACpC,MAAI,WAAW,yBAAyB,OAAO,MAAM,KAAM,QAAO;AAClE,SAAO;AACT;AAmBA,IAAM,mBAAmB;AACzB,IAAM,qBAAqB;AAM3B,SAAS,eAAe,MAAc,QAAyB;AAC7D,QAAM,WAAW,KAAK,kBAAkB;AACxC,QAAM,SAAS,OAAO,kBAAkB;AACxC,WAAS,IAAI,SAAS,QAAQ,MAAM,GAAG,MAAM,IAAI,IAAI,SAAS,QAAQ,QAAQ,IAAI,CAAC,GAAG;AACpF,UAAM,SAAS,SAAS,MAAM,GAAG,CAAC;AAClC,UAAM,QAAQ,SAAS,MAAM,IAAI,OAAO,MAAM;AAC9C,QAAI,CAAC,iBAAiB,KAAK,MAAM,KAAK,CAAC,mBAAmB,KAAK,KAAK,EAAG,QAAO;AAAA,EAChF;AACA,SAAO;AACT;AAEO,SAAS,gBAAgB,OAAsB,SAAkC;AACtF,QAAM,OAAO,MAAM,UAAU,KAAK;AAClC,MAAI,KAAM,QAAO;AAEjB,QAAM,WAAW,QAAQ,UAAU,KAAK,KAAK;AAC7C,QAAM,UAAU,MAAM,OAAO,KAAK,KAAK,MAAM,cAAc,KAAK,KAAK;AACrE,MAAI,CAAC,QAAS,QAAO;AAErB,QAAM,WAAW,wBAAwB,QAAQ,QAAQ;AACzD,MAAI,SAAS,SAAS,yBAAyB,GAAG;AAChD,QAAI,CAAC,SAAU,QAAO;AACtB,QAAI,eAAe,SAAS,QAAQ,EAAG,QAAO;AAAA,EAChD;AAKA,QAAM,CAAC,SAAS,IAAI,QAAQ,EAAE,IAAI,SAAS,MAAM,qBAAqB;AACtE,QAAM,QAAQ,CAAC,SAAiB,KAAK,MAAM,yBAAyB,EAAE,KAAK,QAAQ;AACnF,SAAO,GAAG,MAAM,MAAM,CAAC,GAAG,OAAO,GAAG,MAAM,KAAK,CAAC,GAAG,KAAK;AAC1D;AAeO,SAAS,cAAc,OAAqC;AACjE,QAAM,QACJ,MAAM,KAAK,OAAO,KAAK,KACvB,gBAAgB,OAAO;AAAA,IACrB,UAAU,MAAM,OAAO;AAAA,IACvB,UAAU,MAAM,OAAO;AAAA,EACzB,CAAC;AAEH,QAAM,WAAqB,CAAC;AAC5B,MAAI,MAAO,UAAS,QAAQ,EAAE,UAAU,MAAM;AAC9C,QAAM,cAAc,MAAM,aAAa,KAAK;AAC5C,MAAI,YAAa,UAAS,cAAc;AACxC,SAAO;AACT;","names":[]}
1
+ {"version":3,"sources":["../src/seo.ts"],"sourcesContent":["/**\n * `@reblu/site-next/seo` — the ONE page-title rule (REB-871), shared by the\n * Reblu SITE API (which serves `seo.title` already resolved on every detail\n * endpoint) and by SITEs (index/list routes, or an older API without `seo`).\n *\n * The rule, in order:\n * 1. An entity SEO title (`seoTitle` / `metaTitle`) that is non-empty after\n * trimming is the WHOLE title — used as-is, never suffixed.\n * 2. Otherwise the tenant template (`SiteSettings.seoTitleTemplate`, default\n * `%s | {siteName}`) is applied ONCE to the display title — the entity\n * title on a detail route, the section label on a list/index route.\n * 3. The brand is never duplicated: a display title that already names the\n * site is used alone, and a site with no name drops the brand part.\n *\n * Own tsup entry with ZERO runtime dependencies (no React, no `next` — only\n * the `Metadata` TYPE) so base-api can import it server-side and a SITE can\n * call it from `generateMetadata` with no RSC-boundary cost. It must never\n * carry `'use client'`.\n */\nimport type { Metadata } from 'next'\n\n/** Applied when the tenant never chose a template (`seoTitleTemplate` NULL). */\nexport const DEFAULT_SEO_TITLE_TEMPLATE = '%s | {siteName}'\n/** The display-title placeholder — must appear exactly once in a template. */\nexport const SEO_TITLE_PLACEHOLDER = '%s'\n/** Optional brand placeholder, replaced by `siteName`. */\nexport const SEO_SITE_NAME_PLACEHOLDER = '{siteName}'\n/** Upper bound for a stored template (write-side validation). */\nexport const SEO_TITLE_TEMPLATE_MAX_LENGTH = 60\n/** Recommended length of a full page title in search results. */\nexport const SEO_TITLE_RECOMMENDED_MAX_LENGTH = 60\n\nexport type SeoTitleTemplateIssue = 'missing_placeholder' | 'multiple_placeholders' | 'too_long'\n\n/** `null` when `template` is a valid title template, otherwise why it is not. */\nexport function validateSeoTitleTemplate(template: string): SeoTitleTemplateIssue | null {\n const occurrences = template.split(SEO_TITLE_PLACEHOLDER).length - 1\n if (occurrences === 0) return 'missing_placeholder'\n if (occurrences > 1) return 'multiple_placeholders'\n if (template.length > SEO_TITLE_TEMPLATE_MAX_LENGTH) return 'too_long'\n return null\n}\n\n/**\n * The template to apply: the stored one (trimmed) when valid, otherwise the\n * default. An invalid stored value (hand-edited row) degrades to the default\n * instead of producing a broken title.\n */\nexport function resolveSeoTitleTemplate(template: string | null | undefined): string {\n const trimmed = template?.trim() ?? ''\n if (trimmed && validateSeoTitleTemplate(trimmed) === null) return trimmed\n return DEFAULT_SEO_TITLE_TEMPLATE\n}\n\nexport interface SeoTitleInput {\n /** Entity SEO title (`seoTitle` on pages, `metaTitle` on posts/products/courses). */\n seoTitle?: string | null\n /** Display title of the entity (detail routes). */\n title?: string | null\n /** Label of a list/index route (e.g. \"Blog\"), used when there is no entity title. */\n sectionLabel?: string | null\n}\n\nexport interface SeoTitleContext {\n /** `Config.seoTitleTemplate` — null/undefined/invalid → the default. */\n template?: string | null\n /** `Config.siteName`. */\n siteName?: string | null\n}\n\n/** A letter, digit or combining mark — accents included (Unicode-aware). */\nconst WORD_CHAR_AT_END = /[\\p{L}\\p{N}\\p{M}]$/u\nconst WORD_CHAR_AT_START = /^[\\p{L}\\p{N}\\p{M}]/u\n\n/**\n * Whether `phrase` occurs in `text` as a whole word/phrase (case-insensitive):\n * the brand \"Arte\" is in \"Galería Arte\" but not in \"Cartera\" or \"Artesanía\".\n */\nfunction containsPhrase(text: string, phrase: string): boolean {\n const haystack = text.toLocaleLowerCase()\n const needle = phrase.toLocaleLowerCase()\n for (let i = haystack.indexOf(needle); i !== -1; i = haystack.indexOf(needle, i + 1)) {\n const before = haystack.slice(0, i)\n const after = haystack.slice(i + needle.length)\n if (!WORD_CHAR_AT_END.test(before) && !WORD_CHAR_AT_START.test(after)) return true\n }\n return false\n}\n\nexport function resolveSeoTitle(input: SeoTitleInput, context: SeoTitleContext): string {\n const asIs = input.seoTitle?.trim()\n if (asIs) return asIs\n\n const siteName = context.siteName?.trim() ?? ''\n const display = input.title?.trim() || input.sectionLabel?.trim() || ''\n if (!display) return siteName\n\n const template = resolveSeoTitleTemplate(context.template)\n if (template.includes(SEO_SITE_NAME_PLACEHOLDER)) {\n if (!siteName) return display\n if (containsPhrase(display, siteName)) return display\n }\n\n // Split on the placeholder BEFORE inserting the display title, so the title\n // itself is never scanned for placeholders (applied exactly once) and no\n // `String.replace` special pattern (`$&`, `$1`) can reach it.\n const [before = '', after = ''] = template.split(SEO_TITLE_PLACEHOLDER)\n const brand = (part: string) => part.split(SEO_SITE_NAME_PLACEHOLDER).join(siteName)\n return `${brand(before)}${display}${brand(after)}`.trim()\n}\n\nexport type CanonicalUrlIssue = 'invalid' | 'not_https' | 'not_origin'\n\n/**\n * REB-651 — `null` when `value` is a valid canonical site URL: an absolute\n * `https` origin, with nothing after the host but optional trailing slashes\n * (no path, query, fragment or credentials). Otherwise why it is not.\n */\nexport function validateCanonicalUrl(value: string): CanonicalUrlIssue | null {\n const trimmed = value.trim()\n let url: URL\n try {\n url = new URL(trimmed)\n } catch {\n return 'invalid'\n }\n if (url.protocol !== 'https:') return 'not_https'\n // `?` / `#` with nothing after them parse to an empty search/hash, so the\n // raw string is checked as well: a bare `?` is still not an origin.\n if (url.username || url.password || url.search || url.hash || /[?#]/.test(trimmed)) {\n return 'not_origin'\n }\n if (url.pathname.replace(/\\/+$/, '') !== '') return 'not_origin'\n return null\n}\n\n/**\n * The stored/served form of a canonical site URL: the origin with no trailing\n * slash (`https://example.com`), host lower-cased and default port dropped.\n * `null` when unset or invalid — a hand-edited row never yields a broken\n * `metadataBase`.\n */\nexport function normalizeCanonicalUrl(value: string | null | undefined): string | null {\n const trimmed = value?.trim()\n if (!trimmed || validateCanonicalUrl(trimmed) !== null) return null\n return new URL(trimmed).origin\n}\n\n/** The site-level SEO fields of `GET /api/site/config` (REB-651). */\nexport interface SiteSeoConfig {\n siteName?: string | null\n siteDescription?: string | null\n /** `Config.seoTitle` — the site's own search title. */\n seoTitle?: string | null\n /** `Config.seoDescription` — the site's own search description. */\n seoDescription?: string | null\n /** `Config.canonicalUrl` — the site origin, fed to `metadataBase`. */\n canonicalUrl?: string | null\n}\n\n/**\n * The home page title (REB-651): a non-empty site `seoTitle` is used as-is —\n * never suffixed by the template, the same rule as an entity SEO title —\n * otherwise `siteName`, exactly as before the field existed.\n */\nexport function resolveSiteSeoTitle(config: SiteSeoConfig): string {\n return config.seoTitle?.trim() || config.siteName?.trim() || ''\n}\n\n/** The home page description: `seoDescription`, else `siteDescription`. */\nexport function resolveSiteSeoDescription(config: SiteSeoConfig): string {\n return config.seoDescription?.trim() || config.siteDescription?.trim() || ''\n}\n\nexport interface BuildMetadataInput extends SeoTitleInput {\n /** The detail endpoint's `seo` block — its `title` is already resolved. */\n seo?: { title?: string | null } | null\n /** `GET /api/site/config` (or the subset needed here). */\n config: { seoTitleTemplate?: string | null } & SiteSeoConfig\n description?: string | null\n /**\n * REB-651 — the home / site-level path: the title is the site `seoTitle`\n * as-is (else `siteName`), the description the site `seoDescription` (else\n * `siteDescription`), and a valid `canonicalUrl` becomes `metadataBase`.\n * Explicit on purpose: a page whose title happens to be empty must never be\n * titled with the site's SEO title.\n */\n home?: boolean\n}\n\n/**\n * Next `Metadata` for a SITE route. The title is emitted as `absolute` so a\n * root-layout `title.template` can never append the brand a second time.\n * Description/Open Graph are deliberately minimal here (REB-823 extends them).\n */\nexport function buildMetadata(input: BuildMetadataInput): Metadata {\n if (input.home) return buildSiteMetadata(input)\n\n const title =\n input.seo?.title?.trim() ||\n resolveSeoTitle(input, {\n template: input.config.seoTitleTemplate,\n siteName: input.config.siteName,\n })\n\n const metadata: Metadata = {}\n if (title) metadata.title = { absolute: title }\n const description = input.description?.trim()\n if (description) metadata.description = description\n return metadata\n}\n\nfunction buildSiteMetadata(input: BuildMetadataInput): Metadata {\n const metadata: Metadata = {}\n const canonicalUrl = normalizeCanonicalUrl(input.config.canonicalUrl)\n if (canonicalUrl) metadata.metadataBase = new URL(canonicalUrl)\n\n const title = resolveSiteSeoTitle(input.config)\n if (title) metadata.title = { absolute: title }\n const description = input.description?.trim() || resolveSiteSeoDescription(input.config)\n if (description) metadata.description = description\n\n if (canonicalUrl) metadata.alternates = { canonical: '/' }\n return metadata\n}\n"],"mappings":"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","names":[]}
package/dist/seo.d.cts CHANGED
@@ -54,6 +54,39 @@ interface SeoTitleContext {
54
54
  siteName?: string | null;
55
55
  }
56
56
  declare function resolveSeoTitle(input: SeoTitleInput, context: SeoTitleContext): string;
57
+ type CanonicalUrlIssue = 'invalid' | 'not_https' | 'not_origin';
58
+ /**
59
+ * REB-651 — `null` when `value` is a valid canonical site URL: an absolute
60
+ * `https` origin, with nothing after the host but optional trailing slashes
61
+ * (no path, query, fragment or credentials). Otherwise why it is not.
62
+ */
63
+ declare function validateCanonicalUrl(value: string): CanonicalUrlIssue | null;
64
+ /**
65
+ * The stored/served form of a canonical site URL: the origin with no trailing
66
+ * slash (`https://example.com`), host lower-cased and default port dropped.
67
+ * `null` when unset or invalid — a hand-edited row never yields a broken
68
+ * `metadataBase`.
69
+ */
70
+ declare function normalizeCanonicalUrl(value: string | null | undefined): string | null;
71
+ /** The site-level SEO fields of `GET /api/site/config` (REB-651). */
72
+ interface SiteSeoConfig {
73
+ siteName?: string | null;
74
+ siteDescription?: string | null;
75
+ /** `Config.seoTitle` — the site's own search title. */
76
+ seoTitle?: string | null;
77
+ /** `Config.seoDescription` — the site's own search description. */
78
+ seoDescription?: string | null;
79
+ /** `Config.canonicalUrl` — the site origin, fed to `metadataBase`. */
80
+ canonicalUrl?: string | null;
81
+ }
82
+ /**
83
+ * The home page title (REB-651): a non-empty site `seoTitle` is used as-is —
84
+ * never suffixed by the template, the same rule as an entity SEO title —
85
+ * otherwise `siteName`, exactly as before the field existed.
86
+ */
87
+ declare function resolveSiteSeoTitle(config: SiteSeoConfig): string;
88
+ /** The home page description: `seoDescription`, else `siteDescription`. */
89
+ declare function resolveSiteSeoDescription(config: SiteSeoConfig): string;
57
90
  interface BuildMetadataInput extends SeoTitleInput {
58
91
  /** The detail endpoint's `seo` block — its `title` is already resolved. */
59
92
  seo?: {
@@ -61,10 +94,17 @@ interface BuildMetadataInput extends SeoTitleInput {
61
94
  } | null;
62
95
  /** `GET /api/site/config` (or the subset needed here). */
63
96
  config: {
64
- siteName?: string | null;
65
97
  seoTitleTemplate?: string | null;
66
- };
98
+ } & SiteSeoConfig;
67
99
  description?: string | null;
100
+ /**
101
+ * REB-651 — the home / site-level path: the title is the site `seoTitle`
102
+ * as-is (else `siteName`), the description the site `seoDescription` (else
103
+ * `siteDescription`), and a valid `canonicalUrl` becomes `metadataBase`.
104
+ * Explicit on purpose: a page whose title happens to be empty must never be
105
+ * titled with the site's SEO title.
106
+ */
107
+ home?: boolean;
68
108
  }
69
109
  /**
70
110
  * Next `Metadata` for a SITE route. The title is emitted as `absolute` so a
@@ -73,4 +113,4 @@ interface BuildMetadataInput extends SeoTitleInput {
73
113
  */
74
114
  declare function buildMetadata(input: BuildMetadataInput): Metadata;
75
115
 
76
- export { type BuildMetadataInput, DEFAULT_SEO_TITLE_TEMPLATE, SEO_SITE_NAME_PLACEHOLDER, SEO_TITLE_PLACEHOLDER, SEO_TITLE_RECOMMENDED_MAX_LENGTH, SEO_TITLE_TEMPLATE_MAX_LENGTH, type SeoTitleContext, type SeoTitleInput, type SeoTitleTemplateIssue, buildMetadata, resolveSeoTitle, resolveSeoTitleTemplate, validateSeoTitleTemplate };
116
+ export { type BuildMetadataInput, type CanonicalUrlIssue, DEFAULT_SEO_TITLE_TEMPLATE, SEO_SITE_NAME_PLACEHOLDER, SEO_TITLE_PLACEHOLDER, SEO_TITLE_RECOMMENDED_MAX_LENGTH, SEO_TITLE_TEMPLATE_MAX_LENGTH, type SeoTitleContext, type SeoTitleInput, type SeoTitleTemplateIssue, type SiteSeoConfig, buildMetadata, normalizeCanonicalUrl, resolveSeoTitle, resolveSeoTitleTemplate, resolveSiteSeoDescription, resolveSiteSeoTitle, validateCanonicalUrl, validateSeoTitleTemplate };
package/dist/seo.d.ts CHANGED
@@ -54,6 +54,39 @@ interface SeoTitleContext {
54
54
  siteName?: string | null;
55
55
  }
56
56
  declare function resolveSeoTitle(input: SeoTitleInput, context: SeoTitleContext): string;
57
+ type CanonicalUrlIssue = 'invalid' | 'not_https' | 'not_origin';
58
+ /**
59
+ * REB-651 — `null` when `value` is a valid canonical site URL: an absolute
60
+ * `https` origin, with nothing after the host but optional trailing slashes
61
+ * (no path, query, fragment or credentials). Otherwise why it is not.
62
+ */
63
+ declare function validateCanonicalUrl(value: string): CanonicalUrlIssue | null;
64
+ /**
65
+ * The stored/served form of a canonical site URL: the origin with no trailing
66
+ * slash (`https://example.com`), host lower-cased and default port dropped.
67
+ * `null` when unset or invalid — a hand-edited row never yields a broken
68
+ * `metadataBase`.
69
+ */
70
+ declare function normalizeCanonicalUrl(value: string | null | undefined): string | null;
71
+ /** The site-level SEO fields of `GET /api/site/config` (REB-651). */
72
+ interface SiteSeoConfig {
73
+ siteName?: string | null;
74
+ siteDescription?: string | null;
75
+ /** `Config.seoTitle` — the site's own search title. */
76
+ seoTitle?: string | null;
77
+ /** `Config.seoDescription` — the site's own search description. */
78
+ seoDescription?: string | null;
79
+ /** `Config.canonicalUrl` — the site origin, fed to `metadataBase`. */
80
+ canonicalUrl?: string | null;
81
+ }
82
+ /**
83
+ * The home page title (REB-651): a non-empty site `seoTitle` is used as-is —
84
+ * never suffixed by the template, the same rule as an entity SEO title —
85
+ * otherwise `siteName`, exactly as before the field existed.
86
+ */
87
+ declare function resolveSiteSeoTitle(config: SiteSeoConfig): string;
88
+ /** The home page description: `seoDescription`, else `siteDescription`. */
89
+ declare function resolveSiteSeoDescription(config: SiteSeoConfig): string;
57
90
  interface BuildMetadataInput extends SeoTitleInput {
58
91
  /** The detail endpoint's `seo` block — its `title` is already resolved. */
59
92
  seo?: {
@@ -61,10 +94,17 @@ interface BuildMetadataInput extends SeoTitleInput {
61
94
  } | null;
62
95
  /** `GET /api/site/config` (or the subset needed here). */
63
96
  config: {
64
- siteName?: string | null;
65
97
  seoTitleTemplate?: string | null;
66
- };
98
+ } & SiteSeoConfig;
67
99
  description?: string | null;
100
+ /**
101
+ * REB-651 — the home / site-level path: the title is the site `seoTitle`
102
+ * as-is (else `siteName`), the description the site `seoDescription` (else
103
+ * `siteDescription`), and a valid `canonicalUrl` becomes `metadataBase`.
104
+ * Explicit on purpose: a page whose title happens to be empty must never be
105
+ * titled with the site's SEO title.
106
+ */
107
+ home?: boolean;
68
108
  }
69
109
  /**
70
110
  * Next `Metadata` for a SITE route. The title is emitted as `absolute` so a
@@ -73,4 +113,4 @@ interface BuildMetadataInput extends SeoTitleInput {
73
113
  */
74
114
  declare function buildMetadata(input: BuildMetadataInput): Metadata;
75
115
 
76
- export { type BuildMetadataInput, DEFAULT_SEO_TITLE_TEMPLATE, SEO_SITE_NAME_PLACEHOLDER, SEO_TITLE_PLACEHOLDER, SEO_TITLE_RECOMMENDED_MAX_LENGTH, SEO_TITLE_TEMPLATE_MAX_LENGTH, type SeoTitleContext, type SeoTitleInput, type SeoTitleTemplateIssue, buildMetadata, resolveSeoTitle, resolveSeoTitleTemplate, validateSeoTitleTemplate };
116
+ export { type BuildMetadataInput, type CanonicalUrlIssue, DEFAULT_SEO_TITLE_TEMPLATE, SEO_SITE_NAME_PLACEHOLDER, SEO_TITLE_PLACEHOLDER, SEO_TITLE_RECOMMENDED_MAX_LENGTH, SEO_TITLE_TEMPLATE_MAX_LENGTH, type SeoTitleContext, type SeoTitleInput, type SeoTitleTemplateIssue, type SiteSeoConfig, buildMetadata, normalizeCanonicalUrl, resolveSeoTitle, resolveSeoTitleTemplate, resolveSiteSeoDescription, resolveSiteSeoTitle, validateCanonicalUrl, validateSeoTitleTemplate };
package/dist/seo.js CHANGED
@@ -43,7 +43,34 @@ function resolveSeoTitle(input, context) {
43
43
  const brand = (part) => part.split(SEO_SITE_NAME_PLACEHOLDER).join(siteName);
44
44
  return `${brand(before)}${display}${brand(after)}`.trim();
45
45
  }
46
+ function validateCanonicalUrl(value) {
47
+ const trimmed = value.trim();
48
+ let url;
49
+ try {
50
+ url = new URL(trimmed);
51
+ } catch {
52
+ return "invalid";
53
+ }
54
+ if (url.protocol !== "https:") return "not_https";
55
+ if (url.username || url.password || url.search || url.hash || /[?#]/.test(trimmed)) {
56
+ return "not_origin";
57
+ }
58
+ if (url.pathname.replace(/\/+$/, "") !== "") return "not_origin";
59
+ return null;
60
+ }
61
+ function normalizeCanonicalUrl(value) {
62
+ const trimmed = value?.trim();
63
+ if (!trimmed || validateCanonicalUrl(trimmed) !== null) return null;
64
+ return new URL(trimmed).origin;
65
+ }
66
+ function resolveSiteSeoTitle(config) {
67
+ return config.seoTitle?.trim() || config.siteName?.trim() || "";
68
+ }
69
+ function resolveSiteSeoDescription(config) {
70
+ return config.seoDescription?.trim() || config.siteDescription?.trim() || "";
71
+ }
46
72
  function buildMetadata(input) {
73
+ if (input.home) return buildSiteMetadata(input);
47
74
  const title = input.seo?.title?.trim() || resolveSeoTitle(input, {
48
75
  template: input.config.seoTitleTemplate,
49
76
  siteName: input.config.siteName
@@ -54,6 +81,17 @@ function buildMetadata(input) {
54
81
  if (description) metadata.description = description;
55
82
  return metadata;
56
83
  }
84
+ function buildSiteMetadata(input) {
85
+ const metadata = {};
86
+ const canonicalUrl = normalizeCanonicalUrl(input.config.canonicalUrl);
87
+ if (canonicalUrl) metadata.metadataBase = new URL(canonicalUrl);
88
+ const title = resolveSiteSeoTitle(input.config);
89
+ if (title) metadata.title = { absolute: title };
90
+ const description = input.description?.trim() || resolveSiteSeoDescription(input.config);
91
+ if (description) metadata.description = description;
92
+ if (canonicalUrl) metadata.alternates = { canonical: "/" };
93
+ return metadata;
94
+ }
57
95
  export {
58
96
  DEFAULT_SEO_TITLE_TEMPLATE,
59
97
  SEO_SITE_NAME_PLACEHOLDER,
@@ -61,8 +99,12 @@ export {
61
99
  SEO_TITLE_RECOMMENDED_MAX_LENGTH,
62
100
  SEO_TITLE_TEMPLATE_MAX_LENGTH,
63
101
  buildMetadata,
102
+ normalizeCanonicalUrl,
64
103
  resolveSeoTitle,
65
104
  resolveSeoTitleTemplate,
105
+ resolveSiteSeoDescription,
106
+ resolveSiteSeoTitle,
107
+ validateCanonicalUrl,
66
108
  validateSeoTitleTemplate
67
109
  };
68
110
  //# sourceMappingURL=seo.js.map
package/dist/seo.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/seo.ts"],"sourcesContent":["/**\n * `@reblu/site-next/seo` — the ONE page-title rule (REB-871), shared by the\n * Reblu SITE API (which serves `seo.title` already resolved on every detail\n * endpoint) and by SITEs (index/list routes, or an older API without `seo`).\n *\n * The rule, in order:\n * 1. An entity SEO title (`seoTitle` / `metaTitle`) that is non-empty after\n * trimming is the WHOLE title — used as-is, never suffixed.\n * 2. Otherwise the tenant template (`SiteSettings.seoTitleTemplate`, default\n * `%s | {siteName}`) is applied ONCE to the display title — the entity\n * title on a detail route, the section label on a list/index route.\n * 3. The brand is never duplicated: a display title that already names the\n * site is used alone, and a site with no name drops the brand part.\n *\n * Own tsup entry with ZERO runtime dependencies (no React, no `next` — only\n * the `Metadata` TYPE) so base-api can import it server-side and a SITE can\n * call it from `generateMetadata` with no RSC-boundary cost. It must never\n * carry `'use client'`.\n */\nimport type { Metadata } from 'next'\n\n/** Applied when the tenant never chose a template (`seoTitleTemplate` NULL). */\nexport const DEFAULT_SEO_TITLE_TEMPLATE = '%s | {siteName}'\n/** The display-title placeholder — must appear exactly once in a template. */\nexport const SEO_TITLE_PLACEHOLDER = '%s'\n/** Optional brand placeholder, replaced by `siteName`. */\nexport const SEO_SITE_NAME_PLACEHOLDER = '{siteName}'\n/** Upper bound for a stored template (write-side validation). */\nexport const SEO_TITLE_TEMPLATE_MAX_LENGTH = 60\n/** Recommended length of a full page title in search results. */\nexport const SEO_TITLE_RECOMMENDED_MAX_LENGTH = 60\n\nexport type SeoTitleTemplateIssue = 'missing_placeholder' | 'multiple_placeholders' | 'too_long'\n\n/** `null` when `template` is a valid title template, otherwise why it is not. */\nexport function validateSeoTitleTemplate(template: string): SeoTitleTemplateIssue | null {\n const occurrences = template.split(SEO_TITLE_PLACEHOLDER).length - 1\n if (occurrences === 0) return 'missing_placeholder'\n if (occurrences > 1) return 'multiple_placeholders'\n if (template.length > SEO_TITLE_TEMPLATE_MAX_LENGTH) return 'too_long'\n return null\n}\n\n/**\n * The template to apply: the stored one (trimmed) when valid, otherwise the\n * default. An invalid stored value (hand-edited row) degrades to the default\n * instead of producing a broken title.\n */\nexport function resolveSeoTitleTemplate(template: string | null | undefined): string {\n const trimmed = template?.trim() ?? ''\n if (trimmed && validateSeoTitleTemplate(trimmed) === null) return trimmed\n return DEFAULT_SEO_TITLE_TEMPLATE\n}\n\nexport interface SeoTitleInput {\n /** Entity SEO title (`seoTitle` on pages, `metaTitle` on posts/products/courses). */\n seoTitle?: string | null\n /** Display title of the entity (detail routes). */\n title?: string | null\n /** Label of a list/index route (e.g. \"Blog\"), used when there is no entity title. */\n sectionLabel?: string | null\n}\n\nexport interface SeoTitleContext {\n /** `Config.seoTitleTemplate` — null/undefined/invalid → the default. */\n template?: string | null\n /** `Config.siteName`. */\n siteName?: string | null\n}\n\n/** A letter, digit or combining mark — accents included (Unicode-aware). */\nconst WORD_CHAR_AT_END = /[\\p{L}\\p{N}\\p{M}]$/u\nconst WORD_CHAR_AT_START = /^[\\p{L}\\p{N}\\p{M}]/u\n\n/**\n * Whether `phrase` occurs in `text` as a whole word/phrase (case-insensitive):\n * the brand \"Arte\" is in \"Galería Arte\" but not in \"Cartera\" or \"Artesanía\".\n */\nfunction containsPhrase(text: string, phrase: string): boolean {\n const haystack = text.toLocaleLowerCase()\n const needle = phrase.toLocaleLowerCase()\n for (let i = haystack.indexOf(needle); i !== -1; i = haystack.indexOf(needle, i + 1)) {\n const before = haystack.slice(0, i)\n const after = haystack.slice(i + needle.length)\n if (!WORD_CHAR_AT_END.test(before) && !WORD_CHAR_AT_START.test(after)) return true\n }\n return false\n}\n\nexport function resolveSeoTitle(input: SeoTitleInput, context: SeoTitleContext): string {\n const asIs = input.seoTitle?.trim()\n if (asIs) return asIs\n\n const siteName = context.siteName?.trim() ?? ''\n const display = input.title?.trim() || input.sectionLabel?.trim() || ''\n if (!display) return siteName\n\n const template = resolveSeoTitleTemplate(context.template)\n if (template.includes(SEO_SITE_NAME_PLACEHOLDER)) {\n if (!siteName) return display\n if (containsPhrase(display, siteName)) return display\n }\n\n // Split on the placeholder BEFORE inserting the display title, so the title\n // itself is never scanned for placeholders (applied exactly once) and no\n // `String.replace` special pattern (`$&`, `$1`) can reach it.\n const [before = '', after = ''] = template.split(SEO_TITLE_PLACEHOLDER)\n const brand = (part: string) => part.split(SEO_SITE_NAME_PLACEHOLDER).join(siteName)\n return `${brand(before)}${display}${brand(after)}`.trim()\n}\n\nexport interface BuildMetadataInput extends SeoTitleInput {\n /** The detail endpoint's `seo` block — its `title` is already resolved. */\n seo?: { title?: string | null } | null\n /** `GET /api/site/config` (or the subset needed here). */\n config: { siteName?: string | null; seoTitleTemplate?: string | null }\n description?: string | null\n}\n\n/**\n * Next `Metadata` for a SITE route. The title is emitted as `absolute` so a\n * root-layout `title.template` can never append the brand a second time.\n * Description/Open Graph are deliberately minimal here (REB-823 extends them).\n */\nexport function buildMetadata(input: BuildMetadataInput): Metadata {\n const title =\n input.seo?.title?.trim() ||\n resolveSeoTitle(input, {\n template: input.config.seoTitleTemplate,\n siteName: input.config.siteName,\n })\n\n const metadata: Metadata = {}\n if (title) metadata.title = { absolute: title }\n const description = input.description?.trim()\n if (description) metadata.description = description\n return metadata\n}\n"],"mappings":";AAsBO,IAAM,6BAA6B;AAEnC,IAAM,wBAAwB;AAE9B,IAAM,4BAA4B;AAElC,IAAM,gCAAgC;AAEtC,IAAM,mCAAmC;AAKzC,SAAS,yBAAyB,UAAgD;AACvF,QAAM,cAAc,SAAS,MAAM,qBAAqB,EAAE,SAAS;AACnE,MAAI,gBAAgB,EAAG,QAAO;AAC9B,MAAI,cAAc,EAAG,QAAO;AAC5B,MAAI,SAAS,SAAS,8BAA+B,QAAO;AAC5D,SAAO;AACT;AAOO,SAAS,wBAAwB,UAA6C;AACnF,QAAM,UAAU,UAAU,KAAK,KAAK;AACpC,MAAI,WAAW,yBAAyB,OAAO,MAAM,KAAM,QAAO;AAClE,SAAO;AACT;AAmBA,IAAM,mBAAmB;AACzB,IAAM,qBAAqB;AAM3B,SAAS,eAAe,MAAc,QAAyB;AAC7D,QAAM,WAAW,KAAK,kBAAkB;AACxC,QAAM,SAAS,OAAO,kBAAkB;AACxC,WAAS,IAAI,SAAS,QAAQ,MAAM,GAAG,MAAM,IAAI,IAAI,SAAS,QAAQ,QAAQ,IAAI,CAAC,GAAG;AACpF,UAAM,SAAS,SAAS,MAAM,GAAG,CAAC;AAClC,UAAM,QAAQ,SAAS,MAAM,IAAI,OAAO,MAAM;AAC9C,QAAI,CAAC,iBAAiB,KAAK,MAAM,KAAK,CAAC,mBAAmB,KAAK,KAAK,EAAG,QAAO;AAAA,EAChF;AACA,SAAO;AACT;AAEO,SAAS,gBAAgB,OAAsB,SAAkC;AACtF,QAAM,OAAO,MAAM,UAAU,KAAK;AAClC,MAAI,KAAM,QAAO;AAEjB,QAAM,WAAW,QAAQ,UAAU,KAAK,KAAK;AAC7C,QAAM,UAAU,MAAM,OAAO,KAAK,KAAK,MAAM,cAAc,KAAK,KAAK;AACrE,MAAI,CAAC,QAAS,QAAO;AAErB,QAAM,WAAW,wBAAwB,QAAQ,QAAQ;AACzD,MAAI,SAAS,SAAS,yBAAyB,GAAG;AAChD,QAAI,CAAC,SAAU,QAAO;AACtB,QAAI,eAAe,SAAS,QAAQ,EAAG,QAAO;AAAA,EAChD;AAKA,QAAM,CAAC,SAAS,IAAI,QAAQ,EAAE,IAAI,SAAS,MAAM,qBAAqB;AACtE,QAAM,QAAQ,CAAC,SAAiB,KAAK,MAAM,yBAAyB,EAAE,KAAK,QAAQ;AACnF,SAAO,GAAG,MAAM,MAAM,CAAC,GAAG,OAAO,GAAG,MAAM,KAAK,CAAC,GAAG,KAAK;AAC1D;AAeO,SAAS,cAAc,OAAqC;AACjE,QAAM,QACJ,MAAM,KAAK,OAAO,KAAK,KACvB,gBAAgB,OAAO;AAAA,IACrB,UAAU,MAAM,OAAO;AAAA,IACvB,UAAU,MAAM,OAAO;AAAA,EACzB,CAAC;AAEH,QAAM,WAAqB,CAAC;AAC5B,MAAI,MAAO,UAAS,QAAQ,EAAE,UAAU,MAAM;AAC9C,QAAM,cAAc,MAAM,aAAa,KAAK;AAC5C,MAAI,YAAa,UAAS,cAAc;AACxC,SAAO;AACT;","names":[]}
1
+ {"version":3,"sources":["../src/seo.ts"],"sourcesContent":["/**\n * `@reblu/site-next/seo` — the ONE page-title rule (REB-871), shared by the\n * Reblu SITE API (which serves `seo.title` already resolved on every detail\n * endpoint) and by SITEs (index/list routes, or an older API without `seo`).\n *\n * The rule, in order:\n * 1. An entity SEO title (`seoTitle` / `metaTitle`) that is non-empty after\n * trimming is the WHOLE title — used as-is, never suffixed.\n * 2. Otherwise the tenant template (`SiteSettings.seoTitleTemplate`, default\n * `%s | {siteName}`) is applied ONCE to the display title — the entity\n * title on a detail route, the section label on a list/index route.\n * 3. The brand is never duplicated: a display title that already names the\n * site is used alone, and a site with no name drops the brand part.\n *\n * Own tsup entry with ZERO runtime dependencies (no React, no `next` — only\n * the `Metadata` TYPE) so base-api can import it server-side and a SITE can\n * call it from `generateMetadata` with no RSC-boundary cost. It must never\n * carry `'use client'`.\n */\nimport type { Metadata } from 'next'\n\n/** Applied when the tenant never chose a template (`seoTitleTemplate` NULL). */\nexport const DEFAULT_SEO_TITLE_TEMPLATE = '%s | {siteName}'\n/** The display-title placeholder — must appear exactly once in a template. */\nexport const SEO_TITLE_PLACEHOLDER = '%s'\n/** Optional brand placeholder, replaced by `siteName`. */\nexport const SEO_SITE_NAME_PLACEHOLDER = '{siteName}'\n/** Upper bound for a stored template (write-side validation). */\nexport const SEO_TITLE_TEMPLATE_MAX_LENGTH = 60\n/** Recommended length of a full page title in search results. */\nexport const SEO_TITLE_RECOMMENDED_MAX_LENGTH = 60\n\nexport type SeoTitleTemplateIssue = 'missing_placeholder' | 'multiple_placeholders' | 'too_long'\n\n/** `null` when `template` is a valid title template, otherwise why it is not. */\nexport function validateSeoTitleTemplate(template: string): SeoTitleTemplateIssue | null {\n const occurrences = template.split(SEO_TITLE_PLACEHOLDER).length - 1\n if (occurrences === 0) return 'missing_placeholder'\n if (occurrences > 1) return 'multiple_placeholders'\n if (template.length > SEO_TITLE_TEMPLATE_MAX_LENGTH) return 'too_long'\n return null\n}\n\n/**\n * The template to apply: the stored one (trimmed) when valid, otherwise the\n * default. An invalid stored value (hand-edited row) degrades to the default\n * instead of producing a broken title.\n */\nexport function resolveSeoTitleTemplate(template: string | null | undefined): string {\n const trimmed = template?.trim() ?? ''\n if (trimmed && validateSeoTitleTemplate(trimmed) === null) return trimmed\n return DEFAULT_SEO_TITLE_TEMPLATE\n}\n\nexport interface SeoTitleInput {\n /** Entity SEO title (`seoTitle` on pages, `metaTitle` on posts/products/courses). */\n seoTitle?: string | null\n /** Display title of the entity (detail routes). */\n title?: string | null\n /** Label of a list/index route (e.g. \"Blog\"), used when there is no entity title. */\n sectionLabel?: string | null\n}\n\nexport interface SeoTitleContext {\n /** `Config.seoTitleTemplate` — null/undefined/invalid → the default. */\n template?: string | null\n /** `Config.siteName`. */\n siteName?: string | null\n}\n\n/** A letter, digit or combining mark — accents included (Unicode-aware). */\nconst WORD_CHAR_AT_END = /[\\p{L}\\p{N}\\p{M}]$/u\nconst WORD_CHAR_AT_START = /^[\\p{L}\\p{N}\\p{M}]/u\n\n/**\n * Whether `phrase` occurs in `text` as a whole word/phrase (case-insensitive):\n * the brand \"Arte\" is in \"Galería Arte\" but not in \"Cartera\" or \"Artesanía\".\n */\nfunction containsPhrase(text: string, phrase: string): boolean {\n const haystack = text.toLocaleLowerCase()\n const needle = phrase.toLocaleLowerCase()\n for (let i = haystack.indexOf(needle); i !== -1; i = haystack.indexOf(needle, i + 1)) {\n const before = haystack.slice(0, i)\n const after = haystack.slice(i + needle.length)\n if (!WORD_CHAR_AT_END.test(before) && !WORD_CHAR_AT_START.test(after)) return true\n }\n return false\n}\n\nexport function resolveSeoTitle(input: SeoTitleInput, context: SeoTitleContext): string {\n const asIs = input.seoTitle?.trim()\n if (asIs) return asIs\n\n const siteName = context.siteName?.trim() ?? ''\n const display = input.title?.trim() || input.sectionLabel?.trim() || ''\n if (!display) return siteName\n\n const template = resolveSeoTitleTemplate(context.template)\n if (template.includes(SEO_SITE_NAME_PLACEHOLDER)) {\n if (!siteName) return display\n if (containsPhrase(display, siteName)) return display\n }\n\n // Split on the placeholder BEFORE inserting the display title, so the title\n // itself is never scanned for placeholders (applied exactly once) and no\n // `String.replace` special pattern (`$&`, `$1`) can reach it.\n const [before = '', after = ''] = template.split(SEO_TITLE_PLACEHOLDER)\n const brand = (part: string) => part.split(SEO_SITE_NAME_PLACEHOLDER).join(siteName)\n return `${brand(before)}${display}${brand(after)}`.trim()\n}\n\nexport type CanonicalUrlIssue = 'invalid' | 'not_https' | 'not_origin'\n\n/**\n * REB-651 — `null` when `value` is a valid canonical site URL: an absolute\n * `https` origin, with nothing after the host but optional trailing slashes\n * (no path, query, fragment or credentials). Otherwise why it is not.\n */\nexport function validateCanonicalUrl(value: string): CanonicalUrlIssue | null {\n const trimmed = value.trim()\n let url: URL\n try {\n url = new URL(trimmed)\n } catch {\n return 'invalid'\n }\n if (url.protocol !== 'https:') return 'not_https'\n // `?` / `#` with nothing after them parse to an empty search/hash, so the\n // raw string is checked as well: a bare `?` is still not an origin.\n if (url.username || url.password || url.search || url.hash || /[?#]/.test(trimmed)) {\n return 'not_origin'\n }\n if (url.pathname.replace(/\\/+$/, '') !== '') return 'not_origin'\n return null\n}\n\n/**\n * The stored/served form of a canonical site URL: the origin with no trailing\n * slash (`https://example.com`), host lower-cased and default port dropped.\n * `null` when unset or invalid — a hand-edited row never yields a broken\n * `metadataBase`.\n */\nexport function normalizeCanonicalUrl(value: string | null | undefined): string | null {\n const trimmed = value?.trim()\n if (!trimmed || validateCanonicalUrl(trimmed) !== null) return null\n return new URL(trimmed).origin\n}\n\n/** The site-level SEO fields of `GET /api/site/config` (REB-651). */\nexport interface SiteSeoConfig {\n siteName?: string | null\n siteDescription?: string | null\n /** `Config.seoTitle` — the site's own search title. */\n seoTitle?: string | null\n /** `Config.seoDescription` — the site's own search description. */\n seoDescription?: string | null\n /** `Config.canonicalUrl` — the site origin, fed to `metadataBase`. */\n canonicalUrl?: string | null\n}\n\n/**\n * The home page title (REB-651): a non-empty site `seoTitle` is used as-is —\n * never suffixed by the template, the same rule as an entity SEO title —\n * otherwise `siteName`, exactly as before the field existed.\n */\nexport function resolveSiteSeoTitle(config: SiteSeoConfig): string {\n return config.seoTitle?.trim() || config.siteName?.trim() || ''\n}\n\n/** The home page description: `seoDescription`, else `siteDescription`. */\nexport function resolveSiteSeoDescription(config: SiteSeoConfig): string {\n return config.seoDescription?.trim() || config.siteDescription?.trim() || ''\n}\n\nexport interface BuildMetadataInput extends SeoTitleInput {\n /** The detail endpoint's `seo` block — its `title` is already resolved. */\n seo?: { title?: string | null } | null\n /** `GET /api/site/config` (or the subset needed here). */\n config: { seoTitleTemplate?: string | null } & SiteSeoConfig\n description?: string | null\n /**\n * REB-651 — the home / site-level path: the title is the site `seoTitle`\n * as-is (else `siteName`), the description the site `seoDescription` (else\n * `siteDescription`), and a valid `canonicalUrl` becomes `metadataBase`.\n * Explicit on purpose: a page whose title happens to be empty must never be\n * titled with the site's SEO title.\n */\n home?: boolean\n}\n\n/**\n * Next `Metadata` for a SITE route. The title is emitted as `absolute` so a\n * root-layout `title.template` can never append the brand a second time.\n * Description/Open Graph are deliberately minimal here (REB-823 extends them).\n */\nexport function buildMetadata(input: BuildMetadataInput): Metadata {\n if (input.home) return buildSiteMetadata(input)\n\n const title =\n input.seo?.title?.trim() ||\n resolveSeoTitle(input, {\n template: input.config.seoTitleTemplate,\n siteName: input.config.siteName,\n })\n\n const metadata: Metadata = {}\n if (title) metadata.title = { absolute: title }\n const description = input.description?.trim()\n if (description) metadata.description = description\n return metadata\n}\n\nfunction buildSiteMetadata(input: BuildMetadataInput): Metadata {\n const metadata: Metadata = {}\n const canonicalUrl = normalizeCanonicalUrl(input.config.canonicalUrl)\n if (canonicalUrl) metadata.metadataBase = new URL(canonicalUrl)\n\n const title = resolveSiteSeoTitle(input.config)\n if (title) metadata.title = { absolute: title }\n const description = input.description?.trim() || resolveSiteSeoDescription(input.config)\n if (description) metadata.description = description\n\n if (canonicalUrl) metadata.alternates = { canonical: '/' }\n return metadata\n}\n"],"mappings":"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","names":[]}