@ingram-tech/nk-seo 0.4.0 → 0.5.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/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # @ingram-tech/nk-seo
2
2
 
3
- SEO primitives for Next.js sites, factored out of the patterns every Ingram site
4
- kept re-implementing:
3
+ SEO primitives for Next.js sites, factored out of the patterns Next.js sites
4
+ keep re-implementing:
5
5
 
6
6
  1. **`<JsonLd>`** + **typed schema.org builders** — `faqPage`, `breadcrumbList`,
7
7
  `article`, `softwareApplication`, `organization`, `website`, `person`,
@@ -9,7 +9,12 @@ kept re-implementing:
9
9
  paths and injects your publisher.
10
10
  2. **`createMetadata`** — a Next `Metadata` factory: canonical + OpenGraph +
11
11
  Twitter card from one title/description/path.
12
- 3. **`<HreflangLinks>`** self-referencing canonical plus per-locale `hreflang`
12
+ 3. **`createSitemap` / `createRobots`** `app/sitemap.ts` and `app/robots.ts`
13
+ route helpers; `createRobots` blanket-disallows non-production hosts so
14
+ Vercel preview / branch URLs never get indexed.
15
+ 4. **`ogImageResponse`** (`@ingram-tech/nk-seo/og`) — a branded `next/og` share
16
+ card that sidesteps the Satori multi-child pitfall.
17
+ 5. **`<HreflangLinks>`** — self-referencing canonical plus per-locale `hreflang`
13
18
  alternates (query-param or path-prefix strategy).
14
19
 
15
20
  The package root (`@ingram-tech/nk-seo`) is **pure** — builders and the metadata
@@ -33,17 +38,17 @@ import { createSeo } from "@ingram-tech/nk-seo";
33
38
  const seo = createSeo({
34
39
  baseUrl: getServerUrl(),
35
40
  organization: {
36
- name: "Financica",
37
- url: "https://financica.app",
38
- logo: "https://financica.app/logo.png",
39
- sameAs: ["https://linkedin.com/company/financica"],
41
+ name: "Acme",
42
+ url: "https://example.com",
43
+ logo: "https://example.com/logo.png",
44
+ sameAs: ["https://www.linkedin.com/company/acme"],
40
45
  },
41
46
  });
42
47
 
43
48
  // Homepage:
44
49
  <JsonLd
45
50
  data={seo.softwareApplication({
46
- name: "Financica",
51
+ name: "Acme",
47
52
  applicationCategory: "BusinessApplication",
48
53
  operatingSystem: "Web",
49
54
  offers: { priceCurrency: "EUR", lowPrice: 59, highPrice: 299, offerCount: 2, url: "/pricing" },
@@ -56,11 +61,17 @@ const seo = createSeo({
56
61
  <JsonLd data={seo.breadcrumbs([{ name: "Home", path: "/" }, { name: "Blog", path: "/blog" }, { name: title, path: `/blog/${slug}` }])} />
57
62
  ```
58
63
 
59
- Already have absolute URLs and don't want the factory? The standalone builders
60
- (`faqPage`, `article`, `breadcrumbList`, …) take absolute URLs directly.
64
+ `createSeo` resolves **nested** URL fields too the organization's `url`/`logo`
65
+ and `offers.url` may be site-relative, as in the example above. Already have
66
+ absolute URLs and don't want the factory? The standalone builders (`faqPage`,
67
+ `article`, `breadcrumbList`, …) take absolute URLs directly (they resolve
68
+ nothing).
61
69
 
62
- `<JsonLd data={...} />` accepts a single node or an array (e.g.
63
- `[organization(org), website(site)]` on the homepage).
70
+ Every builder returns a typed node (`FaqPageNode`, `ArticleNode`, …), so the
71
+ shape survives past the call site. `<JsonLd data={...} />` accepts a single node
72
+ or an array (e.g. `[organization(org), website(site)]` on the homepage), and
73
+ escapes `<` on serialization so CMS-sourced strings can't break out of the
74
+ `<script>` tag.
64
75
 
65
76
  ## Page metadata
66
77
 
@@ -69,13 +80,17 @@ Already have absolute URLs and don't want the factory? The standalone builders
69
80
  import { createMetadata } from "@ingram-tech/nk-seo";
70
81
 
71
82
  export const pageMetadata = createMetadata({
72
- baseUrl: "https://ingram.tech",
73
- siteName: "Ingram Technologies",
83
+ baseUrl: "https://example.com",
84
+ siteName: "Acme",
85
+ titleTemplate: "%s | Acme",
74
86
  defaultImage: "/images/og.png",
75
87
  locale: "en_US",
76
- twitterSite: "@IngramTech",
88
+ twitterSite: "@acme",
77
89
  });
78
90
 
91
+ // app/layout.tsx — metadataBase + default title (+ template when configured):
92
+ export const metadata = pageMetadata.root({ description: "The Acme platform." });
93
+
79
94
  // app/services/page.tsx
80
95
  export const metadata = pageMetadata({
81
96
  title: "Services",
@@ -86,7 +101,85 @@ export const metadata = pageMetadata({
86
101
 
87
102
  Produces `title`, `description`, a self-referencing `alternates.canonical`,
88
103
  `openGraph`, and a `summary_large_image` Twitter card. Pass `noIndex`, `keywords`,
89
- `type: "article"`, or per-page `openGraph`/`twitter` overrides as needed.
104
+ `type: "article"`, or per-page `openGraph`/`twitter` overrides as needed. With
105
+ `titleTemplate` set, `pageMetadata.root()` emits `title.template`, so plain page
106
+ titles render as "Services | Acme" without every page appending the suffix.
107
+
108
+ ## Sitemap & robots
109
+
110
+ ```ts
111
+ // app/sitemap.ts
112
+ import { createSitemap } from "@ingram-tech/nk-seo";
113
+
114
+ export default () =>
115
+ createSitemap({
116
+ baseUrl: getServerUrl(), // your deployment's own origin
117
+ routes: ["/", "/pricing", "/docs", "/faq", "/support"],
118
+ });
119
+ ```
120
+
121
+ `"/"` defaults to priority 1, every other route to 0.7; pass objects
122
+ (`{ path, lastModified, changeFrequency, priority, languages }`) to override, or
123
+ set `lastModified` / `defaultChangeFrequency` / `defaultPriority` site-wide.
124
+ Absolute URLs pass through untouched; a `priority` outside 0–1 throws (Google
125
+ would silently reject the whole entry). Localized routes can declare their
126
+ alternates — `languages: { en: "/about", fr: "/fr/about" }` — mirroring what
127
+ `<HreflangLinks>` emits on the page itself.
128
+
129
+ ```ts
130
+ // app/robots.ts
131
+ import { createRobots } from "@ingram-tech/nk-seo";
132
+
133
+ export default () =>
134
+ createRobots({
135
+ baseUrl: getServerUrl(),
136
+ isProduction: process.env.VERCEL_ENV === "production",
137
+ disallow: ["/api/", "/internal/", "/login"],
138
+ });
139
+ ```
140
+
141
+ When `isProduction` is false the whole site is disallowed — the one SEO
142
+ safeguard everyone forgets on Vercel, where preview and branch deployments are
143
+ otherwise crawlable and compete with the production domain for the same content.
144
+
145
+ ## Open Graph image
146
+
147
+ `@ingram-tech/nk-seo/og` is a separate entry (it pulls in `next/og`), so the
148
+ package root and `/components` never carry the renderer.
149
+
150
+ ```tsx
151
+ // app/opengraph-image.tsx (and re-export from app/twitter-image.tsx)
152
+ import { ogImageResponse } from "@ingram-tech/nk-seo/og";
153
+
154
+ export const size = { width: 1200, height: 630 };
155
+ export const contentType = "image/png";
156
+ export const alt = "Acme — Ship faster";
157
+
158
+ export default () =>
159
+ ogImageResponse({
160
+ title: "Ship faster with Acme",
161
+ subtitle: "The all-in-one platform for modern teams.",
162
+ wordmark: "Acme",
163
+ footer: "example.com",
164
+ accent: "#565ac9",
165
+ });
166
+ ```
167
+
168
+ Pass `logo` (absolute URL or data URI) to replace the accent-square mark with
169
+ your logo, and `fonts` + `fontFamily` to render with the brand typeface —
170
+ `fonts` is forwarded to `ImageResponse`, which takes raw TTF/OTF/WOFF data.
171
+
172
+ The template encodes the Satori rule that trips everyone up: every node with
173
+ more than one child sets `display: flex`, and text nodes are never mixed with
174
+ sibling elements — so the headline stays a plain string and the accent rides on
175
+ the mark, not a coloured `<span>` inside the title.
176
+
177
+ No linter or type-check validates Satori-supported CSS (`ImageResponse` accepts
178
+ all of `React.CSSProperties`; Satori silently drops what it doesn't know), so
179
+ the only real validator is rendering. This package renders its template through
180
+ the real satori + resvg pipeline in its own tests; if a site hand-rolls extra
181
+ cards, give it the same guard — a vitest file (node environment) that renders
182
+ each `opengraph-image.tsx` and asserts a valid PNG comes out.
90
183
 
91
184
  ## Hreflang & canonical
92
185
 
@@ -103,11 +196,11 @@ res.headers.set("x-pathname", req.nextUrl.pathname);
103
196
  import { HreflangLinks } from "@ingram-tech/nk-seo/components";
104
197
 
105
198
  // Query-param locales (?hl=fr):
106
- <HreflangLinks baseUrl="https://financica.app" locales={["en", "fr", "nl"]} />
199
+ <HreflangLinks baseUrl="https://example.com" locales={["en", "fr", "nl"]} />
107
200
 
108
201
  // Path-prefix locales (/fr/about), default locale bare, regional hreflang tags:
109
202
  <HreflangLinks
110
- baseUrl="https://malinamore.studio"
203
+ baseUrl="https://example.com"
111
204
  locales={["en", "fr", "nl"]}
112
205
  strategy="prefix"
113
206
  defaultLocale="en"
@@ -116,4 +209,15 @@ import { HreflangLinks } from "@ingram-tech/nk-seo/components";
116
209
  />
117
210
  ```
118
211
 
119
- Pass `pathname` explicitly if you don't use the `x-pathname` header.
212
+ Pass `pathname` explicitly if you don't use the `x-pathname` header. When
213
+ neither is available the component **throws** instead of guessing — a silent
214
+ fallback would canonicalize every page to the homepage, the kind of site-wide
215
+ SEO bug nobody notices for months.
216
+
217
+ Two rules of the road:
218
+
219
+ - **One canonical per page.** If your pages already set `alternates.canonical`
220
+ (e.g. via `createMetadata`), render `<HreflangLinks canonical={false} />`.
221
+ - Building metadata instead of rendering links? The pure `hreflangAlternates`
222
+ (package root) returns the same `{ canonical, links }` for use in
223
+ `generateMetadata`.
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Pure hreflang computation behind `<HreflangLinks>` (from
3
+ * "@ingram-tech/nk-seo/components"). Usable directly wherever a component
4
+ * doesn't fit (e.g. building `Metadata.alternates` in `generateMetadata`).
5
+ */
6
+ export interface HreflangConfig {
7
+ /** Absolute site origin, e.g. "https://acme.example". */
8
+ baseUrl: string;
9
+ /** Locales to emit alternates for, e.g. ["en", "fr", "nl"]. */
10
+ locales: readonly string[];
11
+ /**
12
+ * How locale is encoded in the alternate URLs:
13
+ * - `"query"` (default): `${baseUrl}${path}?${param}=${locale}` for every locale.
14
+ * - `"prefix"`: the default locale stays at the bare path; others get
15
+ * `/${locale}${path}` (matches a localized-rewrite setup).
16
+ */
17
+ strategy?: "query" | "prefix";
18
+ /** Query-param name for the `"query"` strategy. Default `"hl"`. */
19
+ param?: string;
20
+ /** Default (unprefixed) locale for the `"prefix"` strategy. */
21
+ defaultLocale?: string;
22
+ /** Optional locale → hreflang tag map, e.g. `{ en: "en-BE", fr: "fr-BE" }`. */
23
+ hrefLangTags?: Record<string, string>;
24
+ }
25
+ export interface HreflangLink {
26
+ /** The `hreflang` attribute value ("fr-BE", "x-default", …). */
27
+ hrefLang: string;
28
+ href: string;
29
+ }
30
+ export interface HreflangAlternates {
31
+ /** Self-referencing canonical URL of `pathname`. */
32
+ canonical: string;
33
+ /** One link per locale, plus the trailing `x-default` (→ canonical). */
34
+ links: HreflangLink[];
35
+ }
36
+ /**
37
+ * Computes the self-referencing canonical plus per-locale hreflang alternate
38
+ * URLs (and an `x-default`) for `pathname`.
39
+ */
40
+ export declare function hreflangAlternates(config: HreflangConfig, pathname: string): HreflangAlternates;
41
+ //# sourceMappingURL=alternates.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"alternates.d.ts","sourceRoot":"","sources":["../src/alternates.ts"],"names":[],"mappings":"AAEA;;;;GAIG;AACH,MAAM,WAAW,cAAc;IAC9B,yDAAyD;IACzD,OAAO,EAAE,MAAM,CAAC;IAChB,+DAA+D;IAC/D,OAAO,EAAE,SAAS,MAAM,EAAE,CAAC;IAC3B;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,OAAO,GAAG,QAAQ,CAAC;IAC9B,mEAAmE;IACnE,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,+DAA+D;IAC/D,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,+EAA+E;IAC/E,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACtC;AAED,MAAM,WAAW,YAAY;IAC5B,gEAAgE;IAChE,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;CACb;AAED,MAAM,WAAW,kBAAkB;IAClC,oDAAoD;IACpD,SAAS,EAAE,MAAM,CAAC;IAClB,wEAAwE;IACxE,KAAK,EAAE,YAAY,EAAE,CAAC;CACtB;AAED;;;GAGG;AACH,wBAAgB,kBAAkB,CACjC,MAAM,EAAE,cAAc,EACtB,QAAQ,EAAE,MAAM,GACd,kBAAkB,CAyBpB"}
@@ -0,0 +1,30 @@
1
+ import { absoluteUrl } from "./url.js";
2
+ /**
3
+ * Computes the self-referencing canonical plus per-locale hreflang alternate
4
+ * URLs (and an `x-default`) for `pathname`.
5
+ */
6
+ export function hreflangAlternates(config, pathname) {
7
+ const { strategy = "query", param = "hl", defaultLocale, hrefLangTags } = config;
8
+ const canonical = absoluteUrl(pathname, config.baseUrl);
9
+ const hrefFor = (locale) => {
10
+ if (strategy === "prefix") {
11
+ if (defaultLocale && locale === defaultLocale) {
12
+ return canonical;
13
+ }
14
+ const clean = pathname === "/" ? "" : pathname;
15
+ return absoluteUrl(`/${locale}${clean}`, config.baseUrl);
16
+ }
17
+ return `${canonical}?${param}=${locale}`;
18
+ };
19
+ return {
20
+ canonical,
21
+ links: [
22
+ ...config.locales.map((locale) => ({
23
+ hrefLang: hrefLangTags?.[locale] ?? locale,
24
+ href: hrefFor(locale),
25
+ })),
26
+ { hrefLang: "x-default", href: canonical },
27
+ ],
28
+ };
29
+ }
30
+ //# sourceMappingURL=alternates.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"alternates.js","sourceRoot":"","sources":["../src/alternates.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AAwCvC;;;GAGG;AACH,MAAM,UAAU,kBAAkB,CACjC,MAAsB,EACtB,QAAgB;IAEhB,MAAM,EAAE,QAAQ,GAAG,OAAO,EAAE,KAAK,GAAG,IAAI,EAAE,aAAa,EAAE,YAAY,EAAE,GAAG,MAAM,CAAC;IACjF,MAAM,SAAS,GAAG,WAAW,CAAC,QAAQ,EAAE,MAAM,CAAC,OAAO,CAAC,CAAC;IAExD,MAAM,OAAO,GAAG,CAAC,MAAc,EAAU,EAAE;QAC1C,IAAI,QAAQ,KAAK,QAAQ,EAAE,CAAC;YAC3B,IAAI,aAAa,IAAI,MAAM,KAAK,aAAa,EAAE,CAAC;gBAC/C,OAAO,SAAS,CAAC;YAClB,CAAC;YACD,MAAM,KAAK,GAAG,QAAQ,KAAK,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,QAAQ,CAAC;YAC/C,OAAO,WAAW,CAAC,IAAI,MAAM,GAAG,KAAK,EAAE,EAAE,MAAM,CAAC,OAAO,CAAC,CAAC;QAC1D,CAAC;QACD,OAAO,GAAG,SAAS,IAAI,KAAK,IAAI,MAAM,EAAE,CAAC;IAC1C,CAAC,CAAC;IAEF,OAAO;QACN,SAAS;QACT,KAAK,EAAE;YACN,GAAG,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC;gBAClC,QAAQ,EAAE,YAAY,EAAE,CAAC,MAAM,CAAC,IAAI,MAAM;gBAC1C,IAAI,EAAE,OAAO,CAAC,MAAM,CAAC;aACrB,CAAC,CAAC;YACH,EAAE,QAAQ,EAAE,WAAW,EAAE,IAAI,EAAE,SAAS,EAAE;SAC1C;KACD,CAAC;AACH,CAAC"}
@@ -1,3 +1,3 @@
1
- export { JsonLd } from "./json-ld.js";
1
+ export { JsonLd, serializeJsonLd } from "./json-ld.js";
2
2
  export { HreflangLinks, type HreflangLinksProps } from "./hreflang.js";
3
3
  //# sourceMappingURL=components.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"components.d.ts","sourceRoot":"","sources":["../src/components.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC;AACtC,OAAO,EAAE,aAAa,EAAE,KAAK,kBAAkB,EAAE,MAAM,eAAe,CAAC"}
1
+ {"version":3,"file":"components.d.ts","sourceRoot":"","sources":["../src/components.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,MAAM,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AACvD,OAAO,EAAE,aAAa,EAAE,KAAK,kBAAkB,EAAE,MAAM,eAAe,CAAC"}
@@ -1,5 +1,5 @@
1
1
  // React entry: the rendering components. Kept out of the package root so server
2
2
  // code that only needs the builders/metadata never imports React or next/headers.
3
- export { JsonLd } from "./json-ld.js";
3
+ export { JsonLd, serializeJsonLd } from "./json-ld.js";
4
4
  export { HreflangLinks } from "./hreflang.js";
5
5
  //# sourceMappingURL=components.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"components.js","sourceRoot":"","sources":["../src/components.ts"],"names":[],"mappings":"AAAA,gFAAgF;AAChF,kFAAkF;AAClF,OAAO,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC;AACtC,OAAO,EAAE,aAAa,EAA2B,MAAM,eAAe,CAAC"}
1
+ {"version":3,"file":"components.js","sourceRoot":"","sources":["../src/components.ts"],"names":[],"mappings":"AAAA,gFAAgF;AAChF,kFAAkF;AAClF,OAAO,EAAE,MAAM,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AACvD,OAAO,EAAE,aAAa,EAA2B,MAAM,eAAe,CAAC"}
@@ -1,8 +1,5 @@
1
- export interface HreflangLinksProps {
2
- /** Absolute site origin, e.g. "https://financica.app". */
3
- baseUrl: string;
4
- /** Locales to emit `<link rel="alternate" hreflang>` for, e.g. ["en", "fr", "nl"]. */
5
- locales: readonly string[];
1
+ import { type HreflangConfig } from "./alternates.js";
2
+ export interface HreflangLinksProps extends HreflangConfig {
6
3
  /**
7
4
  * Path being rendered. Defaults to the `x-pathname` request header (set it in
8
5
  * middleware: `headers.set("x-pathname", req.nextUrl.pathname)`). Pass
@@ -10,25 +7,20 @@ export interface HreflangLinksProps {
10
7
  */
11
8
  pathname?: string;
12
9
  /**
13
- * How locale is encoded in the alternate URLs:
14
- * - `"query"` (default): `${baseUrl}${path}?${param}=${locale}` for every locale.
15
- * - `"prefix"`: the default locale stays at the bare path; others get
16
- * `/${locale}${path}` (matches a localized-rewrite setup).
10
+ * Emit a self-referencing `<link rel="canonical">`. Default `true`.
11
+ * Disable it if the page's metadata already sets `alternates.canonical`
12
+ * (e.g. via `createMetadata`) a page must not declare two canonicals.
17
13
  */
18
- strategy?: "query" | "prefix";
19
- /** Query-param name for the `"query"` strategy. Default `"hl"`. */
20
- param?: string;
21
- /** Default (unprefixed) locale for the `"prefix"` strategy. */
22
- defaultLocale?: string;
23
- /** Optional locale → hreflang tag map, e.g. `{ en: "en-BE", fr: "fr-BE" }`. */
24
- hrefLangTags?: Record<string, string>;
25
- /** Emit a self-referencing `<link rel="canonical">`. Default `true`. */
26
14
  canonical?: boolean;
27
15
  }
28
16
  /**
29
17
  * Emits `<link rel="canonical">` plus per-locale `<link rel="alternate" hreflang>`
30
18
  * (and an `x-default`) for the current path. Render inside `<head>` (e.g. from
31
19
  * the root layout). Server component — reads the `x-pathname` header by default.
20
+ *
21
+ * Throws when neither `pathname` nor the header is available: silently falling
22
+ * back would canonicalize every page to the homepage, a site-wide
23
+ * duplicate-content bug that is otherwise invisible.
32
24
  */
33
- export declare function HreflangLinks({ baseUrl, locales, pathname, strategy, param, defaultLocale, hrefLangTags, canonical, }: HreflangLinksProps): Promise<import("react").JSX.Element>;
25
+ export declare function HreflangLinks({ pathname, canonical, ...config }: HreflangLinksProps): Promise<import("react").JSX.Element>;
34
26
  //# sourceMappingURL=hreflang.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"hreflang.d.ts","sourceRoot":"","sources":["../src/hreflang.tsx"],"names":[],"mappings":"AAEA,MAAM,WAAW,kBAAkB;IAClC,0DAA0D;IAC1D,OAAO,EAAE,MAAM,CAAC;IAChB,sFAAsF;IACtF,OAAO,EAAE,SAAS,MAAM,EAAE,CAAC;IAC3B;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,OAAO,GAAG,QAAQ,CAAC;IAC9B,mEAAmE;IACnE,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,+DAA+D;IAC/D,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,+EAA+E;IAC/E,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACtC,wEAAwE;IACxE,SAAS,CAAC,EAAE,OAAO,CAAC;CACpB;AAED;;;;GAIG;AACH,wBAAsB,aAAa,CAAC,EACnC,OAAO,EACP,OAAO,EACP,QAAQ,EACR,QAAkB,EAClB,KAAY,EACZ,aAAa,EACb,YAAY,EACZ,SAAgB,GAChB,EAAE,kBAAkB,wCA6BpB"}
1
+ {"version":3,"file":"hreflang.d.ts","sourceRoot":"","sources":["../src/hreflang.tsx"],"names":[],"mappings":"AACA,OAAO,EAAsB,KAAK,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAE1E,MAAM,WAAW,kBAAmB,SAAQ,cAAc;IACzD;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;;OAIG;IACH,SAAS,CAAC,EAAE,OAAO,CAAC;CACpB;AAED;;;;;;;;GAQG;AACH,wBAAsB,aAAa,CAAC,EACnC,QAAQ,EACR,SAAgB,EAChB,GAAG,MAAM,EACT,EAAE,kBAAkB,wCAwBpB"}
package/dist/hreflang.js CHANGED
@@ -1,23 +1,23 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { headers } from "next/headers";
3
+ import { hreflangAlternates } from "./alternates.js";
3
4
  /**
4
5
  * Emits `<link rel="canonical">` plus per-locale `<link rel="alternate" hreflang>`
5
6
  * (and an `x-default`) for the current path. Render inside `<head>` (e.g. from
6
7
  * the root layout). Server component — reads the `x-pathname` header by default.
8
+ *
9
+ * Throws when neither `pathname` nor the header is available: silently falling
10
+ * back would canonicalize every page to the homepage, a site-wide
11
+ * duplicate-content bug that is otherwise invisible.
7
12
  */
8
- export async function HreflangLinks({ baseUrl, locales, pathname, strategy = "query", param = "hl", defaultLocale, hrefLangTags, canonical = true, }) {
9
- const path = pathname ?? (await headers()).get("x-pathname") ?? "/";
10
- const canonicalUrl = `${baseUrl}${path}`;
11
- const hrefFor = (locale) => {
12
- if (strategy === "prefix") {
13
- if (defaultLocale && locale === defaultLocale) {
14
- return canonicalUrl;
15
- }
16
- const clean = path === "/" ? "" : path;
17
- return `${baseUrl}/${locale}${clean}`;
18
- }
19
- return `${canonicalUrl}?${param}=${locale}`;
20
- };
21
- return (_jsxs(_Fragment, { children: [canonical ? _jsx("link", { rel: "canonical", href: canonicalUrl }) : null, locales.map((locale) => (_jsx("link", { rel: "alternate", hrefLang: hrefLangTags?.[locale] ?? locale, href: hrefFor(locale) }, locale))), _jsx("link", { rel: "alternate", hrefLang: "x-default", href: canonicalUrl })] }));
13
+ export async function HreflangLinks({ pathname, canonical = true, ...config }) {
14
+ const path = pathname ?? (await headers()).get("x-pathname");
15
+ if (!path) {
16
+ throw new Error("HreflangLinks: no `pathname` prop and no `x-pathname` request header. " +
17
+ 'Set the header in middleware (`res.headers.set("x-pathname", req.nextUrl.pathname)`) ' +
18
+ "or pass `pathname` explicitly.");
19
+ }
20
+ const { canonical: canonicalUrl, links } = hreflangAlternates(config, path);
21
+ return (_jsxs(_Fragment, { children: [canonical ? _jsx("link", { rel: "canonical", href: canonicalUrl }) : null, links.map((link) => (_jsx("link", { rel: "alternate", hrefLang: link.hrefLang, href: link.href }, link.hrefLang)))] }));
22
22
  }
23
23
  //# sourceMappingURL=hreflang.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"hreflang.js","sourceRoot":"","sources":["../src/hreflang.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AA8BvC;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,aAAa,CAAC,EACnC,OAAO,EACP,OAAO,EACP,QAAQ,EACR,QAAQ,GAAG,OAAO,EAClB,KAAK,GAAG,IAAI,EACZ,aAAa,EACb,YAAY,EACZ,SAAS,GAAG,IAAI,GACI;IACpB,MAAM,IAAI,GAAG,QAAQ,IAAI,CAAC,MAAM,OAAO,EAAE,CAAC,CAAC,GAAG,CAAC,YAAY,CAAC,IAAI,GAAG,CAAC;IACpE,MAAM,YAAY,GAAG,GAAG,OAAO,GAAG,IAAI,EAAE,CAAC;IAEzC,MAAM,OAAO,GAAG,CAAC,MAAc,EAAU,EAAE;QAC1C,IAAI,QAAQ,KAAK,QAAQ,EAAE,CAAC;YAC3B,IAAI,aAAa,IAAI,MAAM,KAAK,aAAa,EAAE,CAAC;gBAC/C,OAAO,YAAY,CAAC;YACrB,CAAC;YACD,MAAM,KAAK,GAAG,IAAI,KAAK,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC;YACvC,OAAO,GAAG,OAAO,IAAI,MAAM,GAAG,KAAK,EAAE,CAAC;QACvC,CAAC;QACD,OAAO,GAAG,YAAY,IAAI,KAAK,IAAI,MAAM,EAAE,CAAC;IAC7C,CAAC,CAAC;IAEF,OAAO,CACN,8BACE,SAAS,CAAC,CAAC,CAAC,eAAM,GAAG,EAAC,WAAW,EAAC,IAAI,EAAE,YAAY,GAAI,CAAC,CAAC,CAAC,IAAI,EAC/D,OAAO,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,CACxB,eAEC,GAAG,EAAC,WAAW,EACf,QAAQ,EAAE,YAAY,EAAE,CAAC,MAAM,CAAC,IAAI,MAAM,EAC1C,IAAI,EAAE,OAAO,CAAC,MAAM,CAAC,IAHhB,MAAM,CAIV,CACF,CAAC,EACF,eAAM,GAAG,EAAC,WAAW,EAAC,QAAQ,EAAC,WAAW,EAAC,IAAI,EAAE,YAAY,GAAI,IAC/D,CACH,CAAC;AACH,CAAC"}
1
+ {"version":3,"file":"hreflang.js","sourceRoot":"","sources":["../src/hreflang.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AACvC,OAAO,EAAE,kBAAkB,EAAuB,MAAM,iBAAiB,CAAC;AAiB1E;;;;;;;;GAQG;AACH,MAAM,CAAC,KAAK,UAAU,aAAa,CAAC,EACnC,QAAQ,EACR,SAAS,GAAG,IAAI,EAChB,GAAG,MAAM,EACW;IACpB,MAAM,IAAI,GAAG,QAAQ,IAAI,CAAC,MAAM,OAAO,EAAE,CAAC,CAAC,GAAG,CAAC,YAAY,CAAC,CAAC;IAC7D,IAAI,CAAC,IAAI,EAAE,CAAC;QACX,MAAM,IAAI,KAAK,CACd,wEAAwE;YACvE,uFAAuF;YACvF,gCAAgC,CACjC,CAAC;IACH,CAAC;IACD,MAAM,EAAE,SAAS,EAAE,YAAY,EAAE,KAAK,EAAE,GAAG,kBAAkB,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC;IAE5E,OAAO,CACN,8BACE,SAAS,CAAC,CAAC,CAAC,eAAM,GAAG,EAAC,WAAW,EAAC,IAAI,EAAE,YAAY,GAAI,CAAC,CAAC,CAAC,IAAI,EAC/D,KAAK,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,CACpB,eAEC,GAAG,EAAC,WAAW,EACf,QAAQ,EAAE,IAAI,CAAC,QAAQ,EACvB,IAAI,EAAE,IAAI,CAAC,IAAI,IAHV,IAAI,CAAC,QAAQ,CAIjB,CACF,CAAC,IACA,CACH,CAAC;AACH,CAAC"}
package/dist/index.d.ts CHANGED
@@ -1,3 +1,5 @@
1
- export { type AggregateRatingInput, article, type ArticleAuthor, type ArticleInput, breadcrumbList, type BreadcrumbItem, type BreadcrumbPath, createSeo, event, type EventInput, type EventLocationInput, faqPage, type FaqItem, type GeoInput, type JsonLdNode, localBusiness, type LocalBusinessInput, type OfferInput, organization, type OrganizationInput, person, type PersonInput, type PostalAddressInput, type Seo, type SeoConfig, softwareApplication, type SoftwareApplicationInput, website, type WebsiteInput, } from "./schema.js";
2
- export { createMetadata, type MetadataSiteConfig, type PageMetadata, type PageMetadataInput, } from "./metadata.js";
1
+ export { type AggregateOfferNode, type AggregateRatingInput, type AggregateRatingNode, article, type ArticleAuthor, type ArticleAuthorNode, type ArticleInput, type ArticleNode, breadcrumbList, type BreadcrumbItem, type BreadcrumbListNode, type BreadcrumbPath, createSeo, event, type EventInput, type EventLocationInput, type EventNode, faqPage, type FaqItem, type FaqPageNode, type GeoCoordinatesNode, type GeoInput, type ImageObjectNode, type JsonLdNode, type ListItemNode, localBusiness, type LocalBusinessInput, type LocalBusinessNode, type OfferInput, type OfferNode, organization, type OrganizationInput, type OrganizationNode, person, type PersonInput, type PersonNode, type PlaceNode, type PostalAddressInput, type PostalAddressNode, type QuestionNode, type Seo, type SeoConfig, softwareApplication, type SoftwareApplicationInput, type SoftwareApplicationNode, type VirtualLocationNode, website, type WebsiteInput, type WebsiteNode, type WithContext, } from "./schema.js";
2
+ export { createMetadata, type MetadataSiteConfig, type PageMetadata, type PageMetadataInput, type RootMetadataInput, } from "./metadata.js";
3
+ export { createRobots, createSitemap, type RobotsConfig, type SitemapConfig, type SitemapLanguages, type SitemapRoute, } from "./routes.js";
4
+ export { hreflangAlternates, type HreflangAlternates, type HreflangConfig, type HreflangLink, } from "./alternates.js";
3
5
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAIA,OAAO,EACN,KAAK,oBAAoB,EACzB,OAAO,EACP,KAAK,aAAa,EAClB,KAAK,YAAY,EACjB,cAAc,EACd,KAAK,cAAc,EACnB,KAAK,cAAc,EACnB,SAAS,EACT,KAAK,EACL,KAAK,UAAU,EACf,KAAK,kBAAkB,EACvB,OAAO,EACP,KAAK,OAAO,EACZ,KAAK,QAAQ,EACb,KAAK,UAAU,EACf,aAAa,EACb,KAAK,kBAAkB,EACvB,KAAK,UAAU,EACf,YAAY,EACZ,KAAK,iBAAiB,EACtB,MAAM,EACN,KAAK,WAAW,EAChB,KAAK,kBAAkB,EACvB,KAAK,GAAG,EACR,KAAK,SAAS,EACd,mBAAmB,EACnB,KAAK,wBAAwB,EAC7B,OAAO,EACP,KAAK,YAAY,GACjB,MAAM,aAAa,CAAC;AACrB,OAAO,EACN,cAAc,EACd,KAAK,kBAAkB,EACvB,KAAK,YAAY,EACjB,KAAK,iBAAiB,GACtB,MAAM,eAAe,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAIA,OAAO,EACN,KAAK,kBAAkB,EACvB,KAAK,oBAAoB,EACzB,KAAK,mBAAmB,EACxB,OAAO,EACP,KAAK,aAAa,EAClB,KAAK,iBAAiB,EACtB,KAAK,YAAY,EACjB,KAAK,WAAW,EAChB,cAAc,EACd,KAAK,cAAc,EACnB,KAAK,kBAAkB,EACvB,KAAK,cAAc,EACnB,SAAS,EACT,KAAK,EACL,KAAK,UAAU,EACf,KAAK,kBAAkB,EACvB,KAAK,SAAS,EACd,OAAO,EACP,KAAK,OAAO,EACZ,KAAK,WAAW,EAChB,KAAK,kBAAkB,EACvB,KAAK,QAAQ,EACb,KAAK,eAAe,EACpB,KAAK,UAAU,EACf,KAAK,YAAY,EACjB,aAAa,EACb,KAAK,kBAAkB,EACvB,KAAK,iBAAiB,EACtB,KAAK,UAAU,EACf,KAAK,SAAS,EACd,YAAY,EACZ,KAAK,iBAAiB,EACtB,KAAK,gBAAgB,EACrB,MAAM,EACN,KAAK,WAAW,EAChB,KAAK,UAAU,EACf,KAAK,SAAS,EACd,KAAK,kBAAkB,EACvB,KAAK,iBAAiB,EACtB,KAAK,YAAY,EACjB,KAAK,GAAG,EACR,KAAK,SAAS,EACd,mBAAmB,EACnB,KAAK,wBAAwB,EAC7B,KAAK,uBAAuB,EAC5B,KAAK,mBAAmB,EACxB,OAAO,EACP,KAAK,YAAY,EACjB,KAAK,WAAW,EAChB,KAAK,WAAW,GAChB,MAAM,aAAa,CAAC;AACrB,OAAO,EACN,cAAc,EACd,KAAK,kBAAkB,EACvB,KAAK,YAAY,EACjB,KAAK,iBAAiB,EACtB,KAAK,iBAAiB,GACtB,MAAM,eAAe,CAAC;AACvB,OAAO,EACN,YAAY,EACZ,aAAa,EACb,KAAK,YAAY,EACjB,KAAK,aAAa,EAClB,KAAK,gBAAgB,EACrB,KAAK,YAAY,GACjB,MAAM,aAAa,CAAC;AACrB,OAAO,EACN,kBAAkB,EAClB,KAAK,kBAAkB,EACvB,KAAK,cAAc,EACnB,KAAK,YAAY,GACjB,MAAM,iBAAiB,CAAC"}
package/dist/index.js CHANGED
@@ -4,4 +4,6 @@
4
4
  // "@ingram-tech/nk-seo/components".
5
5
  export { article, breadcrumbList, createSeo, event, faqPage, localBusiness, organization, person, softwareApplication, website, } from "./schema.js";
6
6
  export { createMetadata, } from "./metadata.js";
7
+ export { createRobots, createSitemap, } from "./routes.js";
8
+ export { hreflangAlternates, } from "./alternates.js";
7
9
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,8EAA8E;AAC9E,gFAAgF;AAChF,sDAAsD;AACtD,oCAAoC;AACpC,OAAO,EAEN,OAAO,EAGP,cAAc,EAGd,SAAS,EACT,KAAK,EAGL,OAAO,EAIP,aAAa,EAGb,YAAY,EAEZ,MAAM,EAKN,mBAAmB,EAEnB,OAAO,GAEP,MAAM,aAAa,CAAC;AACrB,OAAO,EACN,cAAc,GAId,MAAM,eAAe,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,8EAA8E;AAC9E,gFAAgF;AAChF,sDAAsD;AACtD,oCAAoC;AACpC,OAAO,EAIN,OAAO,EAKP,cAAc,EAId,SAAS,EACT,KAAK,EAIL,OAAO,EAQP,aAAa,EAKb,YAAY,EAGZ,MAAM,EASN,mBAAmB,EAInB,OAAO,GAIP,MAAM,aAAa,CAAC;AACrB,OAAO,EACN,cAAc,GAKd,MAAM,eAAe,CAAC;AACvB,OAAO,EACN,YAAY,EACZ,aAAa,GAKb,MAAM,aAAa,CAAC;AACrB,OAAO,EACN,kBAAkB,GAIlB,MAAM,iBAAiB,CAAC"}
package/dist/json-ld.d.ts CHANGED
@@ -1,10 +1,8 @@
1
+ export declare const serializeJsonLd: (data: object | object[]) => string;
1
2
  /**
2
3
  * Renders one or more schema.org JSON-LD nodes into a
3
4
  * `<script type="application/ld+json">`. Works in server and client components.
4
5
  *
5
- * The payload is trusted, server-built structured data (never user input), so
6
- * serialising it into the script body is safe — that is how JSON-LD ships.
7
- *
8
6
  * @example
9
7
  * <JsonLd data={faqPage(items)} />
10
8
  * <JsonLd data={[organization(org), website(site)]} />
@@ -1 +1 @@
1
- {"version":3,"file":"json-ld.d.ts","sourceRoot":"","sources":["../src/json-ld.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,wBAAgB,MAAM,CAAC,EAAE,IAAI,EAAE,EAAE;IAAE,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,CAAA;CAAE,+BAQ3D"}
1
+ {"version":3,"file":"json-ld.d.ts","sourceRoot":"","sources":["../src/json-ld.tsx"],"names":[],"mappings":"AAUA,eAAO,MAAM,eAAe,GAAI,MAAM,MAAM,GAAG,MAAM,EAAE,KAAG,MAMtD,CAAC;AAEL;;;;;;;GAOG;AACH,wBAAgB,MAAM,CAAC,EAAE,IAAI,EAAE,EAAE;IAAE,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,CAAA;CAAE,+BAQ3D"}
package/dist/json-ld.js CHANGED
@@ -1,18 +1,30 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
+ /**
3
+ * JSON.stringify hardened for inline `<script>` bodies. `<` is escaped so a
4
+ * string value containing `</script>` (FAQ answers and article headlines often
5
+ * come from a CMS) cannot terminate the tag and inject markup; U+2028/2029 are
6
+ * escaped because they are valid JSON but not valid JS source.
7
+ */
8
+ // U+2028/U+2029 via RegExp() so the source stays ASCII (a literal invisible
9
+ // character would be one editor "normalization" away from silently vanishing).
10
+ const LINE_SEPARATORS = new RegExp("[\\u2028\\u2029]", "g");
11
+ export const serializeJsonLd = (data) => JSON.stringify(data)
12
+ .replace(/</g, "\\u003c")
13
+ .replace(LINE_SEPARATORS, (char) => {
14
+ const hex = char.codePointAt(0)?.toString(16) ?? "";
15
+ return "\\u" + hex;
16
+ });
2
17
  /**
3
18
  * Renders one or more schema.org JSON-LD nodes into a
4
19
  * `<script type="application/ld+json">`. Works in server and client components.
5
20
  *
6
- * The payload is trusted, server-built structured data (never user input), so
7
- * serialising it into the script body is safe — that is how JSON-LD ships.
8
- *
9
21
  * @example
10
22
  * <JsonLd data={faqPage(items)} />
11
23
  * <JsonLd data={[organization(org), website(site)]} />
12
24
  */
13
25
  export function JsonLd({ data }) {
14
26
  return (_jsx("script", { type: "application/ld+json",
15
- // oxlint-disable-next-line no-danger -- trusted, server-built structured data, not user input
16
- dangerouslySetInnerHTML: { __html: JSON.stringify(data) } }));
27
+ // oxlint-disable-next-line no-danger -- serializeJsonLd escapes `<`, so content strings cannot break out of the script tag
28
+ dangerouslySetInnerHTML: { __html: serializeJsonLd(data) } }));
17
29
  }
18
30
  //# sourceMappingURL=json-ld.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"json-ld.js","sourceRoot":"","sources":["../src/json-ld.tsx"],"names":[],"mappings":";AAAA;;;;;;;;;;GAUG;AACH,MAAM,UAAU,MAAM,CAAC,EAAE,IAAI,EAA+B;IAC3D,OAAO,CACN,iBACC,IAAI,EAAC,qBAAqB;QAC1B,8FAA8F;QAC9F,uBAAuB,EAAE,EAAE,MAAM,EAAE,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,EAAE,GACxD,CACF,CAAC;AACH,CAAC"}
1
+ {"version":3,"file":"json-ld.js","sourceRoot":"","sources":["../src/json-ld.tsx"],"names":[],"mappings":";AAAA;;;;;GAKG;AACH,4EAA4E;AAC5E,+EAA+E;AAC/E,MAAM,eAAe,GAAG,IAAI,MAAM,CAAC,kBAAkB,EAAE,GAAG,CAAC,CAAC;AAE5D,MAAM,CAAC,MAAM,eAAe,GAAG,CAAC,IAAuB,EAAU,EAAE,CAClE,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC;KAClB,OAAO,CAAC,IAAI,EAAE,SAAS,CAAC;KACxB,OAAO,CAAC,eAAe,EAAE,CAAC,IAAI,EAAE,EAAE;IAClC,MAAM,GAAG,GAAG,IAAI,CAAC,WAAW,CAAC,CAAC,CAAC,EAAE,QAAQ,CAAC,EAAE,CAAC,IAAI,EAAE,CAAC;IACpD,OAAO,KAAK,GAAG,GAAG,CAAC;AACpB,CAAC,CAAC,CAAC;AAEL;;;;;;;GAOG;AACH,MAAM,UAAU,MAAM,CAAC,EAAE,IAAI,EAA+B;IAC3D,OAAO,CACN,iBACC,IAAI,EAAC,qBAAqB;QAC1B,2HAA2H;QAC3H,uBAAuB,EAAE,EAAE,MAAM,EAAE,eAAe,CAAC,IAAI,CAAC,EAAE,GACzD,CACF,CAAC;AACH,CAAC"}
@@ -7,6 +7,11 @@ export interface MetadataSiteConfig {
7
7
  baseUrl: string;
8
8
  /** OpenGraph `siteName`. */
9
9
  siteName: string;
10
+ /**
11
+ * `title.template` emitted by `pageMetadata.root()`, e.g. "%s | Acme".
12
+ * Applies to every child page whose title is a plain string.
13
+ */
14
+ titleTemplate?: string;
10
15
  /** Site-relative or absolute default OG/Twitter image. */
11
16
  defaultImage?: string;
12
17
  defaultImageWidth?: number;
@@ -34,21 +39,39 @@ export interface PageMetadataInput {
34
39
  /** Merged into the generated `twitter` (wins on conflict). */
35
40
  twitter?: Partial<NonNullable<Metadata["twitter"]>>;
36
41
  }
42
+ /** Inputs for `pageMetadata.root()` — the root-layout metadata. */
43
+ export interface RootMetadataInput {
44
+ /** `title.default` for pages without their own. Defaults to `siteName`. */
45
+ title?: string;
46
+ description?: string;
47
+ /** Extra `Metadata` merged over the generated object (wins on conflict). */
48
+ overrides?: Metadata;
49
+ }
37
50
  /**
38
51
  * Returns a `pageMetadata(input)` builder that produces a Next `Metadata`
39
52
  * object with a self-referencing canonical, OpenGraph, and Twitter card from
40
53
  * one title/description/path. Collapses the boilerplate every site repeats.
41
54
  *
55
+ * `pageMetadata.root(input?)` builds the root-layout metadata: `metadataBase`,
56
+ * the default title (plus `titleTemplate` when configured), and description —
57
+ * pages then only set their own title/description/path.
58
+ *
42
59
  * @example
43
- * export const metadata = createMetadata({
60
+ * export const pageMetadata = createMetadata({
44
61
  * baseUrl: "https://ingram.tech",
45
62
  * siteName: "Ingram Technologies",
63
+ * titleTemplate: "%s | Ingram Technologies",
46
64
  * defaultImage: "/images/og.png",
47
65
  * twitterSite: "@IngramTech",
48
66
  * });
67
+ * // app/layout.tsx:
68
+ * export const metadata = pageMetadata.root({ description: "AI deployment." });
49
69
  * // in a page:
50
70
  * export const metadata = pageMetadata({ title, description, path: "/services" });
51
71
  */
52
- export declare function createMetadata(site: MetadataSiteConfig): (input: PageMetadataInput) => Metadata;
72
+ export declare function createMetadata(site: MetadataSiteConfig): {
73
+ (input: PageMetadataInput): Metadata;
74
+ root(input?: RootMetadataInput): Metadata;
75
+ };
53
76
  export type PageMetadata = ReturnType<typeof createMetadata>;
54
77
  //# sourceMappingURL=metadata.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"metadata.d.ts","sourceRoot":"","sources":["../src/metadata.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,MAAM,CAAC;AAErC;;GAEG;AACH,MAAM,WAAW,kBAAkB;IAClC,wDAAwD;IACxD,OAAO,EAAE,MAAM,CAAC;IAChB,4BAA4B;IAC5B,QAAQ,EAAE,MAAM,CAAC;IACjB,0DAA0D;IAC1D,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,sCAAsC;IACtC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,oCAAoC;IACpC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,uCAAuC;IACvC,cAAc,CAAC,EAAE,MAAM,CAAC;CACxB;AAED,6DAA6D;AAC7D,MAAM,WAAW,iBAAiB;IACjC,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,sFAAsF;IACtF,IAAI,EAAE,MAAM,CAAC;IACb,oEAAoE;IACpE,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,SAAS,GAAG,SAAS,CAAC;IAC7B,QAAQ,CAAC,EAAE,QAAQ,CAAC,UAAU,CAAC,CAAC;IAChC,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,gEAAgE;IAChE,SAAS,CAAC,EAAE,OAAO,CAAC,WAAW,CAAC,QAAQ,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC;IACxD,8DAA8D;IAC9D,OAAO,CAAC,EAAE,OAAO,CAAC,WAAW,CAAC,QAAQ,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC;CACpD;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,kBAAkB,IAIzB,OAAO,iBAAiB,KAAG,QAAQ,CA2ChE;AAED,MAAM,MAAM,YAAY,GAAG,UAAU,CAAC,OAAO,cAAc,CAAC,CAAC"}
1
+ {"version":3,"file":"metadata.d.ts","sourceRoot":"","sources":["../src/metadata.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,MAAM,CAAC;AAGrC;;GAEG;AACH,MAAM,WAAW,kBAAkB;IAClC,wDAAwD;IACxD,OAAO,EAAE,MAAM,CAAC;IAChB,4BAA4B;IAC5B,QAAQ,EAAE,MAAM,CAAC;IACjB;;;OAGG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,0DAA0D;IAC1D,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,sCAAsC;IACtC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,oCAAoC;IACpC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,uCAAuC;IACvC,cAAc,CAAC,EAAE,MAAM,CAAC;CACxB;AAED,6DAA6D;AAC7D,MAAM,WAAW,iBAAiB;IACjC,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,sFAAsF;IACtF,IAAI,EAAE,MAAM,CAAC;IACb,oEAAoE;IACpE,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,SAAS,GAAG,SAAS,CAAC;IAC7B,QAAQ,CAAC,EAAE,QAAQ,CAAC,UAAU,CAAC,CAAC;IAChC,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,gEAAgE;IAChE,SAAS,CAAC,EAAE,OAAO,CAAC,WAAW,CAAC,QAAQ,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC;IACxD,8DAA8D;IAC9D,OAAO,CAAC,EAAE,OAAO,CAAC,WAAW,CAAC,QAAQ,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC;CACpD;AAED,mEAAmE;AACnE,MAAM,WAAW,iBAAiB;IACjC,2EAA2E;IAC3E,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,4EAA4E;IAC5E,SAAS,CAAC,EAAE,QAAQ,CAAC;CACrB;AAED;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,kBAAkB;YAGzB,iBAAiB,GAAG,QAAQ;iBA6CR,iBAAiB,GAAQ,QAAQ;EAalF;AAED,MAAM,MAAM,YAAY,GAAG,UAAU,CAAC,OAAO,cAAc,CAAC,CAAC"}
package/dist/metadata.js CHANGED
@@ -1,25 +1,34 @@
1
+ import { absoluteUrl } from "./url.js";
1
2
  /**
2
3
  * Returns a `pageMetadata(input)` builder that produces a Next `Metadata`
3
4
  * object with a self-referencing canonical, OpenGraph, and Twitter card from
4
5
  * one title/description/path. Collapses the boilerplate every site repeats.
5
6
  *
7
+ * `pageMetadata.root(input?)` builds the root-layout metadata: `metadataBase`,
8
+ * the default title (plus `titleTemplate` when configured), and description —
9
+ * pages then only set their own title/description/path.
10
+ *
6
11
  * @example
7
- * export const metadata = createMetadata({
12
+ * export const pageMetadata = createMetadata({
8
13
  * baseUrl: "https://ingram.tech",
9
14
  * siteName: "Ingram Technologies",
15
+ * titleTemplate: "%s | Ingram Technologies",
10
16
  * defaultImage: "/images/og.png",
11
17
  * twitterSite: "@IngramTech",
12
18
  * });
19
+ * // app/layout.tsx:
20
+ * export const metadata = pageMetadata.root({ description: "AI deployment." });
13
21
  * // in a page:
14
22
  * export const metadata = pageMetadata({ title, description, path: "/services" });
15
23
  */
16
24
  export function createMetadata(site) {
17
- const absolute = (path) => /^https?:\/\//.test(path) ? path : new URL(path, site.baseUrl).toString();
18
- return function pageMetadata(input) {
25
+ const absolute = (path) => absoluteUrl(path, site.baseUrl);
26
+ function pageMetadata(input) {
19
27
  const imagePath = input.image ?? site.defaultImage;
20
28
  const imageUrl = imagePath ? absolute(imagePath) : undefined;
21
29
  const url = absolute(input.path);
22
30
  return {
31
+ metadataBase: new URL(site.baseUrl),
23
32
  title: input.title,
24
33
  description: input.description,
25
34
  ...(input.keywords ? { keywords: input.keywords } : {}),
@@ -56,6 +65,18 @@ export function createMetadata(site) {
56
65
  ...input.twitter,
57
66
  },
58
67
  };
68
+ }
69
+ pageMetadata.root = function rootMetadata(input = {}) {
70
+ const title = input.title ?? site.siteName;
71
+ return {
72
+ metadataBase: new URL(site.baseUrl),
73
+ title: site.titleTemplate
74
+ ? { default: title, template: site.titleTemplate }
75
+ : title,
76
+ ...(input.description ? { description: input.description } : {}),
77
+ ...input.overrides,
78
+ };
59
79
  };
80
+ return pageMetadata;
60
81
  }
61
82
  //# sourceMappingURL=metadata.js.map