@ingram-tech/nk-seo 0.4.0 → 0.6.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
@@ -22,7 +27,9 @@ components live at `@ingram-tech/nk-seo/components`.
22
27
  bun add @ingram-tech/nk-seo
23
28
  ```
24
29
 
25
- `next` and `react` are optional peers (only the components need them).
30
+ `next` and `react` are optional peers the package root is runtime-free of
31
+ both; the `/components` and `/og` entries need them (`/og` uses `next/og` and
32
+ the React JSX runtime).
26
33
 
27
34
  ## Structured data
28
35
 
@@ -33,17 +40,17 @@ import { createSeo } from "@ingram-tech/nk-seo";
33
40
  const seo = createSeo({
34
41
  baseUrl: getServerUrl(),
35
42
  organization: {
36
- name: "Financica",
37
- url: "https://financica.app",
38
- logo: "https://financica.app/logo.png",
39
- sameAs: ["https://linkedin.com/company/financica"],
43
+ name: "Acme",
44
+ url: "https://example.com",
45
+ logo: "https://example.com/logo.png",
46
+ sameAs: ["https://www.linkedin.com/company/acme"],
40
47
  },
41
48
  });
42
49
 
43
50
  // Homepage:
44
51
  <JsonLd
45
52
  data={seo.softwareApplication({
46
- name: "Financica",
53
+ name: "Acme",
47
54
  applicationCategory: "BusinessApplication",
48
55
  operatingSystem: "Web",
49
56
  offers: { priceCurrency: "EUR", lowPrice: 59, highPrice: 299, offerCount: 2, url: "/pricing" },
@@ -56,11 +63,17 @@ const seo = createSeo({
56
63
  <JsonLd data={seo.breadcrumbs([{ name: "Home", path: "/" }, { name: "Blog", path: "/blog" }, { name: title, path: `/blog/${slug}` }])} />
57
64
  ```
58
65
 
59
- Already have absolute URLs and don't want the factory? The standalone builders
60
- (`faqPage`, `article`, `breadcrumbList`, …) take absolute URLs directly.
66
+ `createSeo` resolves **nested** URL fields too the organization's `url`/`logo`
67
+ and `offers.url` may be site-relative, as in the example above. Already have
68
+ absolute URLs and don't want the factory? The standalone builders (`faqPage`,
69
+ `article`, `breadcrumbList`, …) take absolute URLs directly (they resolve
70
+ nothing).
61
71
 
62
- `<JsonLd data={...} />` accepts a single node or an array (e.g.
63
- `[organization(org), website(site)]` on the homepage).
72
+ Every builder returns a typed node (`FaqPageNode`, `ArticleNode`, …), so the
73
+ shape survives past the call site. `<JsonLd data={...} />` accepts a single node
74
+ or an array (e.g. `[organization(org), website(site)]` on the homepage), and
75
+ escapes `<` on serialization so CMS-sourced strings can't break out of the
76
+ `<script>` tag.
64
77
 
65
78
  ## Page metadata
66
79
 
@@ -69,13 +82,17 @@ Already have absolute URLs and don't want the factory? The standalone builders
69
82
  import { createMetadata } from "@ingram-tech/nk-seo";
70
83
 
71
84
  export const pageMetadata = createMetadata({
72
- baseUrl: "https://ingram.tech",
73
- siteName: "Ingram Technologies",
85
+ baseUrl: "https://example.com",
86
+ siteName: "Acme",
87
+ titleTemplate: "%s | Acme",
74
88
  defaultImage: "/images/og.png",
75
89
  locale: "en_US",
76
- twitterSite: "@IngramTech",
90
+ twitterSite: "@acme",
77
91
  });
78
92
 
93
+ // app/layout.tsx — metadataBase + default title (+ template when configured):
94
+ export const metadata = pageMetadata.root({ description: "The Acme platform." });
95
+
79
96
  // app/services/page.tsx
80
97
  export const metadata = pageMetadata({
81
98
  title: "Services",
@@ -86,28 +103,114 @@ export const metadata = pageMetadata({
86
103
 
87
104
  Produces `title`, `description`, a self-referencing `alternates.canonical`,
88
105
  `openGraph`, and a `summary_large_image` Twitter card. Pass `noIndex`, `keywords`,
89
- `type: "article"`, or per-page `openGraph`/`twitter` overrides as needed.
106
+ `type: "article"`, or per-page `openGraph`/`twitter` overrides as needed. With
107
+ `titleTemplate` set, `pageMetadata.root()` emits `title.template`, so plain page
108
+ titles render as "Services | Acme" without every page appending the suffix.
109
+
110
+ ## Sitemap & robots
111
+
112
+ ```ts
113
+ // app/sitemap.ts
114
+ import { createSitemap } from "@ingram-tech/nk-seo";
115
+
116
+ export default () =>
117
+ createSitemap({
118
+ baseUrl: getServerUrl(), // your deployment's own origin
119
+ routes: ["/", "/pricing", "/docs", "/faq", "/support"],
120
+ });
121
+ ```
122
+
123
+ `"/"` defaults to priority 1, every other route to 0.7; pass objects
124
+ (`{ path, lastModified, changeFrequency, priority, languages }`) to override, or
125
+ set `lastModified` / `defaultChangeFrequency` / `defaultPriority` site-wide.
126
+ Absolute URLs pass through untouched; a `priority` outside 0–1 throws (Google
127
+ would silently reject the whole entry). Localized routes can declare their
128
+ alternates — `languages: { en: "/about", fr: "/fr/about" }` — mirroring what
129
+ `<HreflangLinks>` emits on the page itself.
130
+
131
+ ```ts
132
+ // app/robots.ts
133
+ import { createRobots } from "@ingram-tech/nk-seo";
134
+
135
+ export default () =>
136
+ createRobots({
137
+ baseUrl: getServerUrl(),
138
+ isProduction: process.env.VERCEL_ENV === "production",
139
+ disallow: ["/api/", "/internal/", "/login"],
140
+ });
141
+ ```
142
+
143
+ When `isProduction` is false the whole site is disallowed — the one SEO
144
+ safeguard everyone forgets on Vercel, where preview and branch deployments are
145
+ otherwise crawlable and compete with the production domain for the same content.
146
+
147
+ ## Open Graph image
148
+
149
+ `@ingram-tech/nk-seo/og` is a separate entry (it pulls in `next/og`), so the
150
+ package root and `/components` never carry the renderer.
151
+
152
+ ```tsx
153
+ // app/opengraph-image.tsx (and re-export from app/twitter-image.tsx)
154
+ import { ogImageResponse } from "@ingram-tech/nk-seo/og";
155
+
156
+ export const size = { width: 1200, height: 630 };
157
+ export const contentType = "image/png";
158
+ export const alt = "Acme — Ship faster";
159
+
160
+ export default () =>
161
+ ogImageResponse({
162
+ title: "Ship faster with Acme",
163
+ subtitle: "The all-in-one platform for modern teams.",
164
+ wordmark: "Acme",
165
+ footer: "example.com",
166
+ accent: "#565ac9",
167
+ });
168
+ ```
169
+
170
+ Pass `logo` (absolute URL or data URI) to replace the accent-square mark with
171
+ your logo, and `fonts` + `fontFamily` to render with the brand typeface —
172
+ `fonts` is forwarded to `ImageResponse`, which takes raw TTF/OTF/WOFF data.
173
+
174
+ The template encodes the Satori rule that trips everyone up: every node with
175
+ more than one child sets `display: flex`, and text nodes are never mixed with
176
+ sibling elements — so the headline stays a plain string and the accent rides on
177
+ the mark, not a coloured `<span>` inside the title.
178
+
179
+ No linter or type-check validates Satori-supported CSS (`ImageResponse` accepts
180
+ all of `React.CSSProperties`; Satori silently drops what it doesn't know), so
181
+ the only real validator is rendering. This package renders its template through
182
+ the real satori + resvg pipeline in its own tests; if a site hand-rolls extra
183
+ cards, give it the same guard — a vitest file (node environment) that renders
184
+ each `opengraph-image.tsx` and asserts a valid PNG comes out.
90
185
 
91
186
  ## Hreflang & canonical
92
187
 
93
188
  Render in `<head>` from the root layout. By default it reads the `x-pathname`
94
- request header — set it in middleware:
189
+ request header — set it on the **forwarded request** in middleware (setting it
190
+ on the response does nothing: `headers()` in a server component reads incoming
191
+ request headers):
95
192
 
96
193
  ```ts
97
194
  // middleware.ts
98
- const res = NextResponse.next();
99
- res.headers.set("x-pathname", req.nextUrl.pathname);
195
+ const requestHeaders = new Headers(req.headers);
196
+ requestHeaders.set("x-pathname", req.nextUrl.pathname);
197
+ return NextResponse.next({ request: { headers: requestHeaders } });
100
198
  ```
101
199
 
200
+ Copying `req.headers` first also overwrites any client-spoofed `x-pathname`.
201
+ Note that reading the header (`headers()`) opts the page into dynamic
202
+ rendering — pass `pathname` explicitly (e.g. from route params) on pages that
203
+ must stay static.
204
+
102
205
  ```tsx
103
206
  import { HreflangLinks } from "@ingram-tech/nk-seo/components";
104
207
 
105
208
  // Query-param locales (?hl=fr):
106
- <HreflangLinks baseUrl="https://financica.app" locales={["en", "fr", "nl"]} />
209
+ <HreflangLinks baseUrl="https://example.com" locales={["en", "fr", "nl"]} />
107
210
 
108
211
  // Path-prefix locales (/fr/about), default locale bare, regional hreflang tags:
109
212
  <HreflangLinks
110
- baseUrl="https://malinamore.studio"
213
+ baseUrl="https://example.com"
111
214
  locales={["en", "fr", "nl"]}
112
215
  strategy="prefix"
113
216
  defaultLocale="en"
@@ -116,4 +219,20 @@ import { HreflangLinks } from "@ingram-tech/nk-seo/components";
116
219
  />
117
220
  ```
118
221
 
119
- Pass `pathname` explicitly if you don't use the `x-pathname` header.
222
+ Pass `pathname` explicitly if you don't use the `x-pathname` header. When
223
+ neither is available the component **throws** instead of guessing — a silent
224
+ fallback would canonicalize every page to the homepage, the kind of site-wide
225
+ SEO bug nobody notices for months.
226
+
227
+ Two rules of the road:
228
+
229
+ - **One canonical per page.** If your pages already set `alternates.canonical`
230
+ (e.g. via `createMetadata`), render `<HreflangLinks canonical={false} />`.
231
+ - **The canonical must self-reference.** A localized variant that
232
+ canonicalizes to a different URL makes Google discard the whole hreflang
233
+ cluster. The prefix strategy auto-detects the current locale from the
234
+ (possibly `/fr/…`-prefixed) pathname; the query strategy can't see the query
235
+ string server-side, so pass `currentLocale` from your locale negotiation.
236
+ - Building metadata instead of rendering links? The pure `hreflangAlternates`
237
+ (package root) returns the same `{ canonical, links }` for use in
238
+ `generateMetadata`.
@@ -0,0 +1,49 @@
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
+ /**
23
+ * Locale of the page being rendered. Determines the self-referencing
24
+ * canonical: a localized variant that canonicalizes to another URL makes
25
+ * Google discard the entire hreflang cluster. For the `"prefix"` strategy it
26
+ * is auto-detected from the pathname; for `"query"` the server can't see the
27
+ * query string, so pass it (e.g. from your locale negotiation).
28
+ */
29
+ currentLocale?: string;
30
+ /** Optional locale → hreflang tag map, e.g. `{ en: "en-BE", fr: "fr-BE" }`. */
31
+ hrefLangTags?: Record<string, string>;
32
+ }
33
+ export interface HreflangLink {
34
+ /** The `hreflang` attribute value ("fr-BE", "x-default", …). */
35
+ hrefLang: string;
36
+ href: string;
37
+ }
38
+ export interface HreflangAlternates {
39
+ /** Self-referencing canonical URL of `pathname`. */
40
+ canonical: string;
41
+ /** One link per locale, plus the trailing `x-default` (→ canonical). */
42
+ links: HreflangLink[];
43
+ }
44
+ /**
45
+ * Computes the self-referencing canonical plus per-locale hreflang alternate
46
+ * URLs (and an `x-default`) for `pathname`.
47
+ */
48
+ export declare function hreflangAlternates(config: HreflangConfig, pathname: string): HreflangAlternates;
49
+ //# 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;;;;;;OAMG;IACH,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,CA2DpB"}
@@ -0,0 +1,59 @@
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
+ if (strategy === "prefix" && !defaultLocale) {
9
+ // Without it every locale gets a prefix while canonical/x-default point
10
+ // at a bare path that is no locale's URL — a silent SEO bug.
11
+ throw new Error("hreflangAlternates: `defaultLocale` is required for the prefix strategy.");
12
+ }
13
+ // Prefix strategy: accept both the bare and the locale-prefixed form of the
14
+ // path (middleware's `x-pathname` carries the latter on a real localized
15
+ // route — blindly prepending would emit /fr/fr/about) and detect the
16
+ // current locale from it.
17
+ let basePath = pathname;
18
+ let currentLocale = config.currentLocale;
19
+ if (strategy === "prefix") {
20
+ for (const locale of config.locales) {
21
+ if (pathname === `/${locale}` || pathname.startsWith(`/${locale}/`)) {
22
+ basePath = pathname.slice(locale.length + 1) || "/";
23
+ currentLocale ??= locale;
24
+ break;
25
+ }
26
+ }
27
+ currentLocale ??= defaultLocale;
28
+ }
29
+ /** The default-locale URL — the bare path; also serves as x-default. */
30
+ const defaultUrl = absoluteUrl(basePath, config.baseUrl);
31
+ const hrefFor = (locale) => {
32
+ if (strategy === "prefix") {
33
+ if (locale === defaultLocale)
34
+ return defaultUrl;
35
+ const clean = basePath === "/" ? "" : basePath;
36
+ return absoluteUrl(`/${locale}${clean}`, config.baseUrl);
37
+ }
38
+ return `${defaultUrl}${defaultUrl.includes("?") ? "&" : "?"}${param}=${locale}`;
39
+ };
40
+ // Self-referencing canonical: the current variant's own URL. Canonicalizing
41
+ // a localized variant to the bare path makes Google treat the variants as
42
+ // duplicates and ignore the hreflang annotations entirely.
43
+ const canonical = currentLocale &&
44
+ currentLocale !== defaultLocale &&
45
+ config.locales.includes(currentLocale)
46
+ ? hrefFor(currentLocale)
47
+ : defaultUrl;
48
+ return {
49
+ canonical,
50
+ links: [
51
+ ...config.locales.map((locale) => ({
52
+ hrefLang: hrefLangTags?.[locale] ?? locale,
53
+ href: hrefFor(locale),
54
+ })),
55
+ { hrefLang: "x-default", href: defaultUrl },
56
+ ],
57
+ };
58
+ }
59
+ //# 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;AAgDvC;;;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,IAAI,QAAQ,KAAK,QAAQ,IAAI,CAAC,aAAa,EAAE,CAAC;QAC7C,wEAAwE;QACxE,6DAA6D;QAC7D,MAAM,IAAI,KAAK,CACd,0EAA0E,CAC1E,CAAC;IACH,CAAC;IAED,4EAA4E;IAC5E,yEAAyE;IACzE,qEAAqE;IACrE,0BAA0B;IAC1B,IAAI,QAAQ,GAAG,QAAQ,CAAC;IACxB,IAAI,aAAa,GAAG,MAAM,CAAC,aAAa,CAAC;IACzC,IAAI,QAAQ,KAAK,QAAQ,EAAE,CAAC;QAC3B,KAAK,MAAM,MAAM,IAAI,MAAM,CAAC,OAAO,EAAE,CAAC;YACrC,IAAI,QAAQ,KAAK,IAAI,MAAM,EAAE,IAAI,QAAQ,CAAC,UAAU,CAAC,IAAI,MAAM,GAAG,CAAC,EAAE,CAAC;gBACrE,QAAQ,GAAG,QAAQ,CAAC,KAAK,CAAC,MAAM,CAAC,MAAM,GAAG,CAAC,CAAC,IAAI,GAAG,CAAC;gBACpD,aAAa,KAAK,MAAM,CAAC;gBACzB,MAAM;YACP,CAAC;QACF,CAAC;QACD,aAAa,KAAK,aAAa,CAAC;IACjC,CAAC;IAED,wEAAwE;IACxE,MAAM,UAAU,GAAG,WAAW,CAAC,QAAQ,EAAE,MAAM,CAAC,OAAO,CAAC,CAAC;IAEzD,MAAM,OAAO,GAAG,CAAC,MAAc,EAAU,EAAE;QAC1C,IAAI,QAAQ,KAAK,QAAQ,EAAE,CAAC;YAC3B,IAAI,MAAM,KAAK,aAAa;gBAAE,OAAO,UAAU,CAAC;YAChD,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,UAAU,GAAG,UAAU,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,GAAG,KAAK,IAAI,MAAM,EAAE,CAAC;IACjF,CAAC,CAAC;IAEF,4EAA4E;IAC5E,0EAA0E;IAC1E,2DAA2D;IAC3D,MAAM,SAAS,GACd,aAAa;QACb,aAAa,KAAK,aAAa;QAC/B,MAAM,CAAC,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAC;QACrC,CAAC,CAAC,OAAO,CAAC,aAAa,CAAC;QACxB,CAAC,CAAC,UAAU,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,UAAU,EAAE;SAC3C;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,34 +1,36 @@
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
- * Path being rendered. Defaults to the `x-pathname` request header (set it in
8
- * middleware: `headers.set("x-pathname", req.nextUrl.pathname)`). Pass
9
- * explicitly if you don't use that header (e.g. from route params).
4
+ * Path being rendered. Defaults to the `x-pathname` REQUEST header it must
5
+ * be set on the forwarded request in middleware (a response header is
6
+ * invisible to `headers()`):
7
+ *
8
+ * ```ts
9
+ * const requestHeaders = new Headers(req.headers);
10
+ * requestHeaders.set("x-pathname", req.nextUrl.pathname);
11
+ * return NextResponse.next({ request: { headers: requestHeaders } });
12
+ * ```
13
+ *
14
+ * Pass explicitly if you don't use that header (e.g. from route params) —
15
+ * also the only way to keep the page statically renderable, since reading
16
+ * `headers()` opts the page into dynamic rendering.
10
17
  */
11
18
  pathname?: string;
12
19
  /**
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).
20
+ * Emit a self-referencing `<link rel="canonical">`. Default `true`.
21
+ * Disable it if the page's metadata already sets `alternates.canonical`
22
+ * (e.g. via `createMetadata`) a page must not declare two canonicals.
17
23
  */
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
24
  canonical?: boolean;
27
25
  }
28
26
  /**
29
27
  * Emits `<link rel="canonical">` plus per-locale `<link rel="alternate" hreflang>`
30
28
  * (and an `x-default`) for the current path. Render inside `<head>` (e.g. from
31
29
  * the root layout). Server component — reads the `x-pathname` header by default.
30
+ *
31
+ * Throws when neither `pathname` nor the header is available: silently falling
32
+ * back would canonicalize every page to the homepage, a site-wide
33
+ * duplicate-content bug that is otherwise invisible.
32
34
  */
33
- export declare function HreflangLinks({ baseUrl, locales, pathname, strategy, param, defaultLocale, hrefLangTags, canonical, }: HreflangLinksProps): Promise<import("react").JSX.Element>;
35
+ export declare function HreflangLinks({ pathname, canonical, ...config }: HreflangLinksProps): Promise<import("react").JSX.Element>;
34
36
  //# 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;;;;;;;;;;;;;;OAcG;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,wCA4BpB"}
package/dist/hreflang.js CHANGED
@@ -1,23 +1,25 @@
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 it on the forwarded REQUEST in middleware — " +
18
+ '`const h = new Headers(req.headers); h.set("x-pathname", req.nextUrl.pathname); ' +
19
+ "return NextResponse.next({ request: { headers: h } });` — " +
20
+ "(a response header is invisible to `headers()`), or pass `pathname` explicitly.");
21
+ }
22
+ const { canonical: canonicalUrl, links } = hreflangAlternates(config, path);
23
+ 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} ${link.href}`)))] }));
22
24
  }
23
25
  //# 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;AA2B1E;;;;;;;;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,kDAAkD;YAClD,kFAAkF;YAClF,4DAA4D;YAC5D,iFAAiF,CAClF,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,eAIC,GAAG,EAAC,WAAW,EACf,QAAQ,EAAE,IAAI,CAAC,QAAQ,EACvB,IAAI,EAAE,IAAI,CAAC,IAAI,IAHV,GAAG,IAAI,CAAC,QAAQ,IAAI,IAAI,CAAC,IAAI,EAAE,CAInC,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"}