@reblu/site-next 1.16.0 → 1.18.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/seo.d.cts ADDED
@@ -0,0 +1,76 @@
1
+ import { Metadata } from 'next';
2
+
3
+ /**
4
+ * `@reblu/site-next/seo` — the ONE page-title rule (REB-871), shared by the
5
+ * Reblu SITE API (which serves `seo.title` already resolved on every detail
6
+ * endpoint) and by SITEs (index/list routes, or an older API without `seo`).
7
+ *
8
+ * The rule, in order:
9
+ * 1. An entity SEO title (`seoTitle` / `metaTitle`) that is non-empty after
10
+ * trimming is the WHOLE title — used as-is, never suffixed.
11
+ * 2. Otherwise the tenant template (`SiteSettings.seoTitleTemplate`, default
12
+ * `%s | {siteName}`) is applied ONCE to the display title — the entity
13
+ * title on a detail route, the section label on a list/index route.
14
+ * 3. The brand is never duplicated: a display title that already names the
15
+ * site is used alone, and a site with no name drops the brand part.
16
+ *
17
+ * Own tsup entry with ZERO runtime dependencies (no React, no `next` — only
18
+ * the `Metadata` TYPE) so base-api can import it server-side and a SITE can
19
+ * call it from `generateMetadata` with no RSC-boundary cost. It must never
20
+ * carry `'use client'`.
21
+ */
22
+
23
+ /** Applied when the tenant never chose a template (`seoTitleTemplate` NULL). */
24
+ declare const DEFAULT_SEO_TITLE_TEMPLATE = "%s | {siteName}";
25
+ /** The display-title placeholder — must appear exactly once in a template. */
26
+ declare const SEO_TITLE_PLACEHOLDER = "%s";
27
+ /** Optional brand placeholder, replaced by `siteName`. */
28
+ declare const SEO_SITE_NAME_PLACEHOLDER = "{siteName}";
29
+ /** Upper bound for a stored template (write-side validation). */
30
+ declare const SEO_TITLE_TEMPLATE_MAX_LENGTH = 60;
31
+ /** Recommended length of a full page title in search results. */
32
+ declare const SEO_TITLE_RECOMMENDED_MAX_LENGTH = 60;
33
+ type SeoTitleTemplateIssue = 'missing_placeholder' | 'multiple_placeholders' | 'too_long';
34
+ /** `null` when `template` is a valid title template, otherwise why it is not. */
35
+ declare function validateSeoTitleTemplate(template: string): SeoTitleTemplateIssue | null;
36
+ /**
37
+ * The template to apply: the stored one (trimmed) when valid, otherwise the
38
+ * default. An invalid stored value (hand-edited row) degrades to the default
39
+ * instead of producing a broken title.
40
+ */
41
+ declare function resolveSeoTitleTemplate(template: string | null | undefined): string;
42
+ interface SeoTitleInput {
43
+ /** Entity SEO title (`seoTitle` on pages, `metaTitle` on posts/products/courses). */
44
+ seoTitle?: string | null;
45
+ /** Display title of the entity (detail routes). */
46
+ title?: string | null;
47
+ /** Label of a list/index route (e.g. "Blog"), used when there is no entity title. */
48
+ sectionLabel?: string | null;
49
+ }
50
+ interface SeoTitleContext {
51
+ /** `Config.seoTitleTemplate` — null/undefined/invalid → the default. */
52
+ template?: string | null;
53
+ /** `Config.siteName`. */
54
+ siteName?: string | null;
55
+ }
56
+ declare function resolveSeoTitle(input: SeoTitleInput, context: SeoTitleContext): string;
57
+ interface BuildMetadataInput extends SeoTitleInput {
58
+ /** The detail endpoint's `seo` block — its `title` is already resolved. */
59
+ seo?: {
60
+ title?: string | null;
61
+ } | null;
62
+ /** `GET /api/site/config` (or the subset needed here). */
63
+ config: {
64
+ siteName?: string | null;
65
+ seoTitleTemplate?: string | null;
66
+ };
67
+ description?: string | null;
68
+ }
69
+ /**
70
+ * Next `Metadata` for a SITE route. The title is emitted as `absolute` so a
71
+ * root-layout `title.template` can never append the brand a second time.
72
+ * Description/Open Graph are deliberately minimal here (REB-823 extends them).
73
+ */
74
+ declare function buildMetadata(input: BuildMetadataInput): Metadata;
75
+
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 };
package/dist/seo.d.ts ADDED
@@ -0,0 +1,76 @@
1
+ import { Metadata } from 'next';
2
+
3
+ /**
4
+ * `@reblu/site-next/seo` — the ONE page-title rule (REB-871), shared by the
5
+ * Reblu SITE API (which serves `seo.title` already resolved on every detail
6
+ * endpoint) and by SITEs (index/list routes, or an older API without `seo`).
7
+ *
8
+ * The rule, in order:
9
+ * 1. An entity SEO title (`seoTitle` / `metaTitle`) that is non-empty after
10
+ * trimming is the WHOLE title — used as-is, never suffixed.
11
+ * 2. Otherwise the tenant template (`SiteSettings.seoTitleTemplate`, default
12
+ * `%s | {siteName}`) is applied ONCE to the display title — the entity
13
+ * title on a detail route, the section label on a list/index route.
14
+ * 3. The brand is never duplicated: a display title that already names the
15
+ * site is used alone, and a site with no name drops the brand part.
16
+ *
17
+ * Own tsup entry with ZERO runtime dependencies (no React, no `next` — only
18
+ * the `Metadata` TYPE) so base-api can import it server-side and a SITE can
19
+ * call it from `generateMetadata` with no RSC-boundary cost. It must never
20
+ * carry `'use client'`.
21
+ */
22
+
23
+ /** Applied when the tenant never chose a template (`seoTitleTemplate` NULL). */
24
+ declare const DEFAULT_SEO_TITLE_TEMPLATE = "%s | {siteName}";
25
+ /** The display-title placeholder — must appear exactly once in a template. */
26
+ declare const SEO_TITLE_PLACEHOLDER = "%s";
27
+ /** Optional brand placeholder, replaced by `siteName`. */
28
+ declare const SEO_SITE_NAME_PLACEHOLDER = "{siteName}";
29
+ /** Upper bound for a stored template (write-side validation). */
30
+ declare const SEO_TITLE_TEMPLATE_MAX_LENGTH = 60;
31
+ /** Recommended length of a full page title in search results. */
32
+ declare const SEO_TITLE_RECOMMENDED_MAX_LENGTH = 60;
33
+ type SeoTitleTemplateIssue = 'missing_placeholder' | 'multiple_placeholders' | 'too_long';
34
+ /** `null` when `template` is a valid title template, otherwise why it is not. */
35
+ declare function validateSeoTitleTemplate(template: string): SeoTitleTemplateIssue | null;
36
+ /**
37
+ * The template to apply: the stored one (trimmed) when valid, otherwise the
38
+ * default. An invalid stored value (hand-edited row) degrades to the default
39
+ * instead of producing a broken title.
40
+ */
41
+ declare function resolveSeoTitleTemplate(template: string | null | undefined): string;
42
+ interface SeoTitleInput {
43
+ /** Entity SEO title (`seoTitle` on pages, `metaTitle` on posts/products/courses). */
44
+ seoTitle?: string | null;
45
+ /** Display title of the entity (detail routes). */
46
+ title?: string | null;
47
+ /** Label of a list/index route (e.g. "Blog"), used when there is no entity title. */
48
+ sectionLabel?: string | null;
49
+ }
50
+ interface SeoTitleContext {
51
+ /** `Config.seoTitleTemplate` — null/undefined/invalid → the default. */
52
+ template?: string | null;
53
+ /** `Config.siteName`. */
54
+ siteName?: string | null;
55
+ }
56
+ declare function resolveSeoTitle(input: SeoTitleInput, context: SeoTitleContext): string;
57
+ interface BuildMetadataInput extends SeoTitleInput {
58
+ /** The detail endpoint's `seo` block — its `title` is already resolved. */
59
+ seo?: {
60
+ title?: string | null;
61
+ } | null;
62
+ /** `GET /api/site/config` (or the subset needed here). */
63
+ config: {
64
+ siteName?: string | null;
65
+ seoTitleTemplate?: string | null;
66
+ };
67
+ description?: string | null;
68
+ }
69
+ /**
70
+ * Next `Metadata` for a SITE route. The title is emitted as `absolute` so a
71
+ * root-layout `title.template` can never append the brand a second time.
72
+ * Description/Open Graph are deliberately minimal here (REB-823 extends them).
73
+ */
74
+ declare function buildMetadata(input: BuildMetadataInput): Metadata;
75
+
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 };
package/dist/seo.js ADDED
@@ -0,0 +1,68 @@
1
+ // src/seo.ts
2
+ var DEFAULT_SEO_TITLE_TEMPLATE = "%s | {siteName}";
3
+ var SEO_TITLE_PLACEHOLDER = "%s";
4
+ var SEO_SITE_NAME_PLACEHOLDER = "{siteName}";
5
+ var SEO_TITLE_TEMPLATE_MAX_LENGTH = 60;
6
+ var SEO_TITLE_RECOMMENDED_MAX_LENGTH = 60;
7
+ function validateSeoTitleTemplate(template) {
8
+ const occurrences = template.split(SEO_TITLE_PLACEHOLDER).length - 1;
9
+ if (occurrences === 0) return "missing_placeholder";
10
+ if (occurrences > 1) return "multiple_placeholders";
11
+ if (template.length > SEO_TITLE_TEMPLATE_MAX_LENGTH) return "too_long";
12
+ return null;
13
+ }
14
+ function resolveSeoTitleTemplate(template) {
15
+ const trimmed = template?.trim() ?? "";
16
+ if (trimmed && validateSeoTitleTemplate(trimmed) === null) return trimmed;
17
+ return DEFAULT_SEO_TITLE_TEMPLATE;
18
+ }
19
+ var WORD_CHAR_AT_END = /[\p{L}\p{N}\p{M}]$/u;
20
+ var WORD_CHAR_AT_START = /^[\p{L}\p{N}\p{M}]/u;
21
+ function containsPhrase(text, phrase) {
22
+ const haystack = text.toLocaleLowerCase();
23
+ const needle = phrase.toLocaleLowerCase();
24
+ for (let i = haystack.indexOf(needle); i !== -1; i = haystack.indexOf(needle, i + 1)) {
25
+ const before = haystack.slice(0, i);
26
+ const after = haystack.slice(i + needle.length);
27
+ if (!WORD_CHAR_AT_END.test(before) && !WORD_CHAR_AT_START.test(after)) return true;
28
+ }
29
+ return false;
30
+ }
31
+ function resolveSeoTitle(input, context) {
32
+ const asIs = input.seoTitle?.trim();
33
+ if (asIs) return asIs;
34
+ const siteName = context.siteName?.trim() ?? "";
35
+ const display = input.title?.trim() || input.sectionLabel?.trim() || "";
36
+ if (!display) return siteName;
37
+ const template = resolveSeoTitleTemplate(context.template);
38
+ if (template.includes(SEO_SITE_NAME_PLACEHOLDER)) {
39
+ if (!siteName) return display;
40
+ if (containsPhrase(display, siteName)) return display;
41
+ }
42
+ const [before = "", after = ""] = template.split(SEO_TITLE_PLACEHOLDER);
43
+ const brand = (part) => part.split(SEO_SITE_NAME_PLACEHOLDER).join(siteName);
44
+ return `${brand(before)}${display}${brand(after)}`.trim();
45
+ }
46
+ function buildMetadata(input) {
47
+ const title = input.seo?.title?.trim() || resolveSeoTitle(input, {
48
+ template: input.config.seoTitleTemplate,
49
+ siteName: input.config.siteName
50
+ });
51
+ const metadata = {};
52
+ if (title) metadata.title = { absolute: title };
53
+ const description = input.description?.trim();
54
+ if (description) metadata.description = description;
55
+ return metadata;
56
+ }
57
+ export {
58
+ DEFAULT_SEO_TITLE_TEMPLATE,
59
+ SEO_SITE_NAME_PLACEHOLDER,
60
+ SEO_TITLE_PLACEHOLDER,
61
+ SEO_TITLE_RECOMMENDED_MAX_LENGTH,
62
+ SEO_TITLE_TEMPLATE_MAX_LENGTH,
63
+ buildMetadata,
64
+ resolveSeoTitle,
65
+ resolveSeoTitleTemplate,
66
+ validateSeoTitleTemplate
67
+ };
68
+ //# sourceMappingURL=seo.js.map
@@ -0,0 +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,3 +1,5 @@
1
+ import { CookieBanner } from '@reblu/site-contracts';
2
+
1
3
  /**
2
4
  * `../consent-shared` — the truly isomorphic core of cookie consent (REB-642):
3
5
  * the cookie name, the category taxonomy, and fail-closed parse/serialise.
@@ -68,4 +70,64 @@ declare function parseConsentCookie(raw: string | null | undefined): ConsentCook
68
70
  /** Serialises a consent payload for storage in the cookie. */
69
71
  declare function serializeConsentCookie(payload: ConsentCookiePayload): string;
70
72
 
71
- export { CONSENT_CATEGORIES as C, DEFAULT_CONSENT_CATEGORIES as D, CONSENT_COOKIE_NAME as a, type ConsentCategory as b, type ConsentCategoryState as c, type ConsentCookiePayload as d, parseConsentCookie as p, serializeConsentCookie as s };
73
+ /**
74
+ * Copy for `<CookieBanner>`. SDK-owned defaults in Spanish (every Reblu SITE
75
+ * renders `<html lang='es'>` today).
76
+ *
77
+ * REB-642 §7.1 recommends tenant-editable copy be limited to a TITLE/BODY
78
+ * override for v1 — the per-category descriptions, the "necessary" copy and
79
+ * every button label stay fixed, because the copy is a compliance surface: a
80
+ * tenant writing "we use cookies — OK" with no reject option is the exact
81
+ * misconfiguration this design exists to prevent. This type is intentionally
82
+ * a `Partial<ConsentBannerTexts>` at the call site (see `CookieBanner`), so a
83
+ * later tenant-configuration slice can override `title`/`description` without
84
+ * touching this component's contract.
85
+ */
86
+ interface ConsentBannerTexts {
87
+ title: string;
88
+ description: string;
89
+ acceptLabel: string;
90
+ rejectLabel: string;
91
+ manageLabel: string;
92
+ policyLinkLabel: string;
93
+ preferencesTitle: string;
94
+ preferencesDescription: string;
95
+ saveLabel: string;
96
+ necessaryLabel: string;
97
+ necessaryDescription: string;
98
+ categoryLabels: Record<ConsentCategory, string>;
99
+ categoryDescriptions: Record<ConsentCategory, string>;
100
+ }
101
+ declare const DEFAULT_CONSENT_BANNER_TEXTS: ConsentBannerTexts;
102
+ /**
103
+ * Merges `overrides` onto `DEFAULT_CONSENT_BANNER_TEXTS` (REB-845).
104
+ *
105
+ * A plain `{ ...defaults, ...overrides }` spread lets a BLANK override win —
106
+ * nexus persists `""` for a cookie-banner copy field nobody filled in (see
107
+ * `cookieBannerSchema`'s doc comment in base-api), so a naive merge rendered
108
+ * an empty title, empty description and three blank buttons instead of the
109
+ * SDK's Spanish defaults. An empty or whitespace-only string here means the
110
+ * SAME THING as `undefined`: "no override — use the default."
111
+ *
112
+ * Same for a PRESENT key holding `undefined` or `null` (R3-001 review
113
+ * finding): the spread copies that value right over the default because the
114
+ * key still exists on `overrides`, so `value == null` is checked first,
115
+ * before the string/blank check, rather than relying on `typeof value ===
116
+ * 'string'` to fall through.
117
+ */
118
+ declare function mergeConsentBannerTexts(overrides?: Partial<ConsentBannerTexts>): ConsentBannerTexts;
119
+ /**
120
+ * Maps the `/api/site/analytics` `cookieBanner` contract shape onto
121
+ * `Partial<ConsentBannerTexts>` for `<CookieBanner texts={...}>`, dropping
122
+ * `null` and blank fields so a SITE never has to hand-roll this mapping (and
123
+ * never has to re-implement the blank-is-unset rule `mergeConsentBannerTexts`
124
+ * already applies on the way in).
125
+ *
126
+ * Only the 5 fields REB-642 §7.1 makes tenant-editable map to copy;
127
+ * `cookieBanner.enabled` and `cookieBanner.policyUrl` are not `texts` —
128
+ * `enabled` gates whether `<CookieBanner>` renders at all, and `policyUrl` is
129
+ * `<CookieBanner policyHref>`, not a text key.
130
+ */
131
+ declare function cookieBannerToConsentTexts(cookieBanner: Pick<CookieBanner, 'title' | 'body' | 'acceptLabel' | 'rejectLabel' | 'manageLabel'> | null | undefined): Partial<ConsentBannerTexts>;
132
+
133
+ export { CONSENT_CATEGORIES as C, DEFAULT_CONSENT_BANNER_TEXTS as D, CONSENT_COOKIE_NAME as a, type ConsentBannerTexts as b, type ConsentCategory as c, type ConsentCategoryState as d, type ConsentCookiePayload as e, DEFAULT_CONSENT_CATEGORIES as f, cookieBannerToConsentTexts as g, mergeConsentBannerTexts as m, parseConsentCookie as p, serializeConsentCookie as s };
@@ -1,3 +1,5 @@
1
+ import { CookieBanner } from '@reblu/site-contracts';
2
+
1
3
  /**
2
4
  * `../consent-shared` — the truly isomorphic core of cookie consent (REB-642):
3
5
  * the cookie name, the category taxonomy, and fail-closed parse/serialise.
@@ -68,4 +70,64 @@ declare function parseConsentCookie(raw: string | null | undefined): ConsentCook
68
70
  /** Serialises a consent payload for storage in the cookie. */
69
71
  declare function serializeConsentCookie(payload: ConsentCookiePayload): string;
70
72
 
71
- export { CONSENT_CATEGORIES as C, DEFAULT_CONSENT_CATEGORIES as D, CONSENT_COOKIE_NAME as a, type ConsentCategory as b, type ConsentCategoryState as c, type ConsentCookiePayload as d, parseConsentCookie as p, serializeConsentCookie as s };
73
+ /**
74
+ * Copy for `<CookieBanner>`. SDK-owned defaults in Spanish (every Reblu SITE
75
+ * renders `<html lang='es'>` today).
76
+ *
77
+ * REB-642 §7.1 recommends tenant-editable copy be limited to a TITLE/BODY
78
+ * override for v1 — the per-category descriptions, the "necessary" copy and
79
+ * every button label stay fixed, because the copy is a compliance surface: a
80
+ * tenant writing "we use cookies — OK" with no reject option is the exact
81
+ * misconfiguration this design exists to prevent. This type is intentionally
82
+ * a `Partial<ConsentBannerTexts>` at the call site (see `CookieBanner`), so a
83
+ * later tenant-configuration slice can override `title`/`description` without
84
+ * touching this component's contract.
85
+ */
86
+ interface ConsentBannerTexts {
87
+ title: string;
88
+ description: string;
89
+ acceptLabel: string;
90
+ rejectLabel: string;
91
+ manageLabel: string;
92
+ policyLinkLabel: string;
93
+ preferencesTitle: string;
94
+ preferencesDescription: string;
95
+ saveLabel: string;
96
+ necessaryLabel: string;
97
+ necessaryDescription: string;
98
+ categoryLabels: Record<ConsentCategory, string>;
99
+ categoryDescriptions: Record<ConsentCategory, string>;
100
+ }
101
+ declare const DEFAULT_CONSENT_BANNER_TEXTS: ConsentBannerTexts;
102
+ /**
103
+ * Merges `overrides` onto `DEFAULT_CONSENT_BANNER_TEXTS` (REB-845).
104
+ *
105
+ * A plain `{ ...defaults, ...overrides }` spread lets a BLANK override win —
106
+ * nexus persists `""` for a cookie-banner copy field nobody filled in (see
107
+ * `cookieBannerSchema`'s doc comment in base-api), so a naive merge rendered
108
+ * an empty title, empty description and three blank buttons instead of the
109
+ * SDK's Spanish defaults. An empty or whitespace-only string here means the
110
+ * SAME THING as `undefined`: "no override — use the default."
111
+ *
112
+ * Same for a PRESENT key holding `undefined` or `null` (R3-001 review
113
+ * finding): the spread copies that value right over the default because the
114
+ * key still exists on `overrides`, so `value == null` is checked first,
115
+ * before the string/blank check, rather than relying on `typeof value ===
116
+ * 'string'` to fall through.
117
+ */
118
+ declare function mergeConsentBannerTexts(overrides?: Partial<ConsentBannerTexts>): ConsentBannerTexts;
119
+ /**
120
+ * Maps the `/api/site/analytics` `cookieBanner` contract shape onto
121
+ * `Partial<ConsentBannerTexts>` for `<CookieBanner texts={...}>`, dropping
122
+ * `null` and blank fields so a SITE never has to hand-roll this mapping (and
123
+ * never has to re-implement the blank-is-unset rule `mergeConsentBannerTexts`
124
+ * already applies on the way in).
125
+ *
126
+ * Only the 5 fields REB-642 §7.1 makes tenant-editable map to copy;
127
+ * `cookieBanner.enabled` and `cookieBanner.policyUrl` are not `texts` —
128
+ * `enabled` gates whether `<CookieBanner>` renders at all, and `policyUrl` is
129
+ * `<CookieBanner policyHref>`, not a text key.
130
+ */
131
+ declare function cookieBannerToConsentTexts(cookieBanner: Pick<CookieBanner, 'title' | 'body' | 'acceptLabel' | 'rejectLabel' | 'manageLabel'> | null | undefined): Partial<ConsentBannerTexts>;
132
+
133
+ export { CONSENT_CATEGORIES as C, DEFAULT_CONSENT_BANNER_TEXTS as D, CONSENT_COOKIE_NAME as a, type ConsentBannerTexts as b, type ConsentCategory as c, type ConsentCategoryState as d, type ConsentCookiePayload as e, DEFAULT_CONSENT_CATEGORIES as f, cookieBannerToConsentTexts as g, mergeConsentBannerTexts as m, parseConsentCookie as p, serializeConsentCookie as s };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reblu/site-next",
3
- "version": "1.16.0",
3
+ "version": "1.18.0",
4
4
  "description": "Next.js RSC glue for Reblu SITEs — cache + CTA + auth server/client components.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Reblu — Adolfo Unturbe",
@@ -221,6 +221,16 @@
221
221
  "types": "./dist/opening-hours.d.cts",
222
222
  "default": "./dist/opening-hours.cjs"
223
223
  }
224
+ },
225
+ "./seo": {
226
+ "import": {
227
+ "types": "./dist/seo.d.ts",
228
+ "default": "./dist/seo.js"
229
+ },
230
+ "require": {
231
+ "types": "./dist/seo.d.cts",
232
+ "default": "./dist/seo.cjs"
233
+ }
224
234
  }
225
235
  },
226
236
  "publishConfig": {
@@ -240,9 +250,9 @@
240
250
  "dependencies": {
241
251
  "@radix-ui/react-dialog": "^1.1.15",
242
252
  "zustand": "^5.0.9",
243
- "@reblu/site-client": "^5.3.1",
244
- "@reblu/site-contracts": "^1.6.1",
245
- "@reblu/site-ui": "^0.7.0"
253
+ "@reblu/site-contracts": "^1.11.0",
254
+ "@reblu/site-ui": "^0.7.0",
255
+ "@reblu/site-client": "^5.4.0"
246
256
  },
247
257
  "devDependencies": {
248
258
  "@testing-library/jest-dom": "^6.9.1",