@reblu/site-next 1.17.0 → 1.19.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/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # @reblu/site-next
2
2
 
3
+ ## 1.19.0
4
+
5
+ ### Minor Changes
6
+
7
+ - fd17765: `@reblu/site-next/seo`: home / site-level metadata (REB-651). `buildMetadata({ config, home: true })` titles the home page with the site `seoTitle` as-is (never suffixed by the template; empty → `siteName`), describes it with `seoDescription` (empty → `siteDescription`), and turns a valid `canonicalUrl` into `metadataBase` plus `alternates.canonical`, so relative Open Graph paths resolve. New helpers: `resolveSiteSeoTitle`, `resolveSiteSeoDescription`, `validateCanonicalUrl` and `normalizeCanonicalUrl`.
8
+
9
+ ### Patch Changes
10
+
11
+ - Updated dependencies [fd17765]
12
+ - @reblu/site-contracts@1.12.0
13
+
14
+ ## 1.18.0
15
+
16
+ ### Minor Changes
17
+
18
+ - e4dff25: New `@reblu/site-next/seo` subpath (REB-871) — the one page-title rule, shared with the Reblu SITE API. `resolveSeoTitle({ seoTitle, title, sectionLabel }, { template, siteName })`: a non-empty entity SEO title is used as-is (never suffixed); otherwise the tenant template (`Config.seoTitleTemplate`, default `%s | {siteName}`) is applied exactly once to the display title or the list route's section label, and the brand is never duplicated. `buildMetadata({ seo, config, ... })` returns Next `Metadata` with an absolute title (a root-layout `title.template` can no longer append the brand twice). Also exports `DEFAULT_SEO_TITLE_TEMPLATE`, `validateSeoTitleTemplate` and `resolveSeoTitleTemplate`. Dependency-free, no `'use client'`.
19
+
20
+ ### Patch Changes
21
+
22
+ - Updated dependencies [e4dff25]
23
+ - @reblu/site-contracts@1.11.0
24
+
3
25
  ## 1.17.0
4
26
 
5
27
  ### Minor Changes
package/README.md CHANGED
@@ -642,6 +642,83 @@ whitespace-only value for any key the same as `undefined` — it always falls
642
642
  back to that key's default rather than rendering empty. There is no need to
643
643
  `?.trim()` a value before passing it.
644
644
 
645
+ ## Page titles — `buildMetadata` (REB-871)
646
+
647
+ `@reblu/site-next/seo` (dependency-free, no `'use client'`) holds the one
648
+ page-title rule the Reblu SITE API also applies server-side:
649
+
650
+ - an entity SEO title (`seoTitle` / `metaTitle`) that is set is the **whole**
651
+ title — used as-is, the site name is never appended;
652
+ - otherwise the tenant template (`config.seoTitleTemplate`, default
653
+ `%s | {siteName}`) is applied **once** to the display title — or to the
654
+ section label on a list/index route;
655
+ - the brand is never duplicated.
656
+
657
+ Detail endpoints already return `seo.title` resolved, so a detail route is a
658
+ drop-in:
659
+
660
+ ```ts
661
+ // app/servicios/[slug]/page.tsx
662
+ import type { Metadata } from 'next'
663
+ import { buildMetadata } from '@reblu/site-next/seo'
664
+
665
+ export async function generateMetadata({ params }): Promise<Metadata> {
666
+ const { slug } = await params
667
+ const [page, config] = await Promise.all([reblu.pages.bySlug(slug), reblu.config.get()])
668
+ return buildMetadata({ seo: page.seo, title: page.title, seoTitle: page.seoTitle, config })
669
+ }
670
+
671
+ // app/blog/page.tsx — list/index route: the template with the section label
672
+ export async function generateMetadata(): Promise<Metadata> {
673
+ return buildMetadata({ sectionLabel: 'Blog', config: await reblu.config.get() })
674
+ }
675
+ ```
676
+
677
+ `buildMetadata` emits `title: { absolute }`, so a `title.template` in the root
678
+ layout can never append the brand a second time — drop any hand-written
679
+ `` `${title} · Brand` `` from your routes. Passing `title`/`seoTitle` alongside
680
+ `seo` keeps the route correct against an older API that does not send `seo`
681
+ yet. Need the string only? `resolveSeoTitle({ seoTitle, title, sectionLabel },
682
+ { template: config.seoTitleTemplate, siteName: config.siteName })`.
683
+ Description/Open Graph are intentionally minimal here (REB-823 extends them).
684
+
685
+ ### Home page and canonical URL — `buildMetadata({ config, home: true })` (REB-651)
686
+
687
+ The site has its own search title and description in nexus (Ajustes → SEO y
688
+ Redes), separate from `siteName`/`siteDescription`, which stay the identity
689
+ used in breadcrumbs and chrome. `GET /api/site/config` serves them as
690
+ `seoTitle`, `seoDescription` and `canonicalUrl` (all `null` when unset).
691
+
692
+ ```ts
693
+ // app/page.tsx — the home page: no hardcoded title, description or domain
694
+ import type { Metadata } from 'next'
695
+ import { buildMetadata } from '@reblu/site-next/seo'
696
+
697
+ export async function generateMetadata(): Promise<Metadata> {
698
+ return buildMetadata({ config: await reblu.config.get(), home: true })
699
+ }
700
+
701
+ // app/layout.tsx — every route resolves relative Open Graph paths against it
702
+ import { normalizeCanonicalUrl } from '@reblu/site-next/seo'
703
+
704
+ export async function generateMetadata(): Promise<Metadata> {
705
+ const config = await reblu.config.get()
706
+ const siteUrl = normalizeCanonicalUrl(config.canonicalUrl) ?? process.env.NEXT_PUBLIC_SITE_URL!
707
+ return { metadataBase: new URL(siteUrl) /* , title, openGraph… */ }
708
+ }
709
+ ```
710
+
711
+ - `config.seoTitle`, when set, is the **whole** home title — used as-is, never
712
+ suffixed by the template. Empty → `siteName`, exactly as before.
713
+ - `config.seoDescription` falls back to `siteDescription`.
714
+ - A valid `config.canonicalUrl` (an `https` origin) becomes `metadataBase`
715
+ plus `alternates.canonical: '/'`, so an `ogImageUrl` given as a relative
716
+ path resolves against the site's domain. The env var becomes a fallback.
717
+
718
+ `home: true` is explicit: a route whose title happens to be empty never picks
719
+ up the site's SEO title. Need the strings only? `resolveSiteSeoTitle(config)`
720
+ and `resolveSiteSeoDescription(config)`.
721
+
645
722
  ## Structured data (JSON-LD)
646
723
 
647
724
  `@reblu/site-next/json-ld` (REB-622, server-only, dependency-free) renders one
package/dist/seo.cjs ADDED
@@ -0,0 +1,147 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/seo.ts
21
+ var seo_exports = {};
22
+ __export(seo_exports, {
23
+ DEFAULT_SEO_TITLE_TEMPLATE: () => DEFAULT_SEO_TITLE_TEMPLATE,
24
+ SEO_SITE_NAME_PLACEHOLDER: () => SEO_SITE_NAME_PLACEHOLDER,
25
+ SEO_TITLE_PLACEHOLDER: () => SEO_TITLE_PLACEHOLDER,
26
+ SEO_TITLE_RECOMMENDED_MAX_LENGTH: () => SEO_TITLE_RECOMMENDED_MAX_LENGTH,
27
+ SEO_TITLE_TEMPLATE_MAX_LENGTH: () => SEO_TITLE_TEMPLATE_MAX_LENGTH,
28
+ buildMetadata: () => buildMetadata,
29
+ normalizeCanonicalUrl: () => normalizeCanonicalUrl,
30
+ resolveSeoTitle: () => resolveSeoTitle,
31
+ resolveSeoTitleTemplate: () => resolveSeoTitleTemplate,
32
+ resolveSiteSeoDescription: () => resolveSiteSeoDescription,
33
+ resolveSiteSeoTitle: () => resolveSiteSeoTitle,
34
+ validateCanonicalUrl: () => validateCanonicalUrl,
35
+ validateSeoTitleTemplate: () => validateSeoTitleTemplate
36
+ });
37
+ module.exports = __toCommonJS(seo_exports);
38
+ var DEFAULT_SEO_TITLE_TEMPLATE = "%s | {siteName}";
39
+ var SEO_TITLE_PLACEHOLDER = "%s";
40
+ var SEO_SITE_NAME_PLACEHOLDER = "{siteName}";
41
+ var SEO_TITLE_TEMPLATE_MAX_LENGTH = 60;
42
+ var SEO_TITLE_RECOMMENDED_MAX_LENGTH = 60;
43
+ function validateSeoTitleTemplate(template) {
44
+ const occurrences = template.split(SEO_TITLE_PLACEHOLDER).length - 1;
45
+ if (occurrences === 0) return "missing_placeholder";
46
+ if (occurrences > 1) return "multiple_placeholders";
47
+ if (template.length > SEO_TITLE_TEMPLATE_MAX_LENGTH) return "too_long";
48
+ return null;
49
+ }
50
+ function resolveSeoTitleTemplate(template) {
51
+ const trimmed = template?.trim() ?? "";
52
+ if (trimmed && validateSeoTitleTemplate(trimmed) === null) return trimmed;
53
+ return DEFAULT_SEO_TITLE_TEMPLATE;
54
+ }
55
+ var WORD_CHAR_AT_END = /[\p{L}\p{N}\p{M}]$/u;
56
+ var WORD_CHAR_AT_START = /^[\p{L}\p{N}\p{M}]/u;
57
+ function containsPhrase(text, phrase) {
58
+ const haystack = text.toLocaleLowerCase();
59
+ const needle = phrase.toLocaleLowerCase();
60
+ for (let i = haystack.indexOf(needle); i !== -1; i = haystack.indexOf(needle, i + 1)) {
61
+ const before = haystack.slice(0, i);
62
+ const after = haystack.slice(i + needle.length);
63
+ if (!WORD_CHAR_AT_END.test(before) && !WORD_CHAR_AT_START.test(after)) return true;
64
+ }
65
+ return false;
66
+ }
67
+ function resolveSeoTitle(input, context) {
68
+ const asIs = input.seoTitle?.trim();
69
+ if (asIs) return asIs;
70
+ const siteName = context.siteName?.trim() ?? "";
71
+ const display = input.title?.trim() || input.sectionLabel?.trim() || "";
72
+ if (!display) return siteName;
73
+ const template = resolveSeoTitleTemplate(context.template);
74
+ if (template.includes(SEO_SITE_NAME_PLACEHOLDER)) {
75
+ if (!siteName) return display;
76
+ if (containsPhrase(display, siteName)) return display;
77
+ }
78
+ const [before = "", after = ""] = template.split(SEO_TITLE_PLACEHOLDER);
79
+ const brand = (part) => part.split(SEO_SITE_NAME_PLACEHOLDER).join(siteName);
80
+ return `${brand(before)}${display}${brand(after)}`.trim();
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
+ }
108
+ function buildMetadata(input) {
109
+ if (input.home) return buildSiteMetadata(input);
110
+ const title = input.seo?.title?.trim() || resolveSeoTitle(input, {
111
+ template: input.config.seoTitleTemplate,
112
+ siteName: input.config.siteName
113
+ });
114
+ const metadata = {};
115
+ if (title) metadata.title = { absolute: title };
116
+ const description = input.description?.trim();
117
+ if (description) metadata.description = description;
118
+ return metadata;
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
+ }
131
+ // Annotate the CommonJS export names for ESM import in node:
132
+ 0 && (module.exports = {
133
+ DEFAULT_SEO_TITLE_TEMPLATE,
134
+ SEO_SITE_NAME_PLACEHOLDER,
135
+ SEO_TITLE_PLACEHOLDER,
136
+ SEO_TITLE_RECOMMENDED_MAX_LENGTH,
137
+ SEO_TITLE_TEMPLATE_MAX_LENGTH,
138
+ buildMetadata,
139
+ normalizeCanonicalUrl,
140
+ resolveSeoTitle,
141
+ resolveSeoTitleTemplate,
142
+ resolveSiteSeoDescription,
143
+ resolveSiteSeoTitle,
144
+ validateCanonicalUrl,
145
+ validateSeoTitleTemplate
146
+ });
147
+ //# sourceMappingURL=seo.cjs.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 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 ADDED
@@ -0,0 +1,116 @@
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
+ 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;
90
+ interface BuildMetadataInput extends SeoTitleInput {
91
+ /** The detail endpoint's `seo` block — its `title` is already resolved. */
92
+ seo?: {
93
+ title?: string | null;
94
+ } | null;
95
+ /** `GET /api/site/config` (or the subset needed here). */
96
+ config: {
97
+ seoTitleTemplate?: string | null;
98
+ } & SiteSeoConfig;
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;
108
+ }
109
+ /**
110
+ * Next `Metadata` for a SITE route. The title is emitted as `absolute` so a
111
+ * root-layout `title.template` can never append the brand a second time.
112
+ * Description/Open Graph are deliberately minimal here (REB-823 extends them).
113
+ */
114
+ declare function buildMetadata(input: BuildMetadataInput): Metadata;
115
+
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 ADDED
@@ -0,0 +1,116 @@
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
+ 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;
90
+ interface BuildMetadataInput extends SeoTitleInput {
91
+ /** The detail endpoint's `seo` block — its `title` is already resolved. */
92
+ seo?: {
93
+ title?: string | null;
94
+ } | null;
95
+ /** `GET /api/site/config` (or the subset needed here). */
96
+ config: {
97
+ seoTitleTemplate?: string | null;
98
+ } & SiteSeoConfig;
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;
108
+ }
109
+ /**
110
+ * Next `Metadata` for a SITE route. The title is emitted as `absolute` so a
111
+ * root-layout `title.template` can never append the brand a second time.
112
+ * Description/Open Graph are deliberately minimal here (REB-823 extends them).
113
+ */
114
+ declare function buildMetadata(input: BuildMetadataInput): Metadata;
115
+
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 ADDED
@@ -0,0 +1,110 @@
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 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
+ }
72
+ function buildMetadata(input) {
73
+ if (input.home) return buildSiteMetadata(input);
74
+ const title = input.seo?.title?.trim() || resolveSeoTitle(input, {
75
+ template: input.config.seoTitleTemplate,
76
+ siteName: input.config.siteName
77
+ });
78
+ const metadata = {};
79
+ if (title) metadata.title = { absolute: title };
80
+ const description = input.description?.trim();
81
+ if (description) metadata.description = description;
82
+ return metadata;
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
+ }
95
+ export {
96
+ DEFAULT_SEO_TITLE_TEMPLATE,
97
+ SEO_SITE_NAME_PLACEHOLDER,
98
+ SEO_TITLE_PLACEHOLDER,
99
+ SEO_TITLE_RECOMMENDED_MAX_LENGTH,
100
+ SEO_TITLE_TEMPLATE_MAX_LENGTH,
101
+ buildMetadata,
102
+ normalizeCanonicalUrl,
103
+ resolveSeoTitle,
104
+ resolveSeoTitleTemplate,
105
+ resolveSiteSeoDescription,
106
+ resolveSiteSeoTitle,
107
+ validateCanonicalUrl,
108
+ validateSeoTitleTemplate
109
+ };
110
+ //# 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 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reblu/site-next",
3
- "version": "1.17.0",
3
+ "version": "1.19.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,8 +250,8 @@
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",
253
+ "@reblu/site-client": "^5.4.0",
254
+ "@reblu/site-contracts": "^1.12.0",
245
255
  "@reblu/site-ui": "^0.7.0"
246
256
  },
247
257
  "devDependencies": {