@ingram-tech/nk-seo 0.7.0 → 0.8.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
@@ -341,13 +341,42 @@ Two rules of the road:
341
341
 
342
342
  - **One canonical per page.** If your pages already set `alternates.canonical`
343
343
  (e.g. via `createMetadata`), render `<HreflangLinks canonical={false} />`.
344
- - **The canonical must self-reference.** A localized variant that
345
- canonicalizes to a different URL makes Google discard the whole hreflang
346
- cluster. The prefix strategy auto-detects the current locale from the
347
- (possibly `/fr/…`-prefixed) pathname; the query strategy can't see the query
348
- string server-side, so pass `currentLocale` from your locale negotiation.
344
+ - **The canonical must self-reference, and it follows the ADDRESS, not the
345
+ rendered language.** A localized variant that canonicalizes to a different URL
346
+ makes Google discard the whole hreflang cluster. Under `"query"` the bare path
347
+ is the negotiating `x-default` and belongs to no locale, so `/pricing`
348
+ canonicalizes to `/pricing` even while negotiation renders it in French — pass
349
+ `currentLocale` only when the URL itself names a locale. The prefix strategy
350
+ auto-detects it from the (possibly `/fr/…`-prefixed) pathname; the query
351
+ strategy can't see the query string server-side, so
352
+ [`hreflangConfigFor(routing)`](../nk-i18n/README.md) from
353
+ `@ingram-tech/nk-i18n/next` is the wiring that gets this right for you.
349
354
  - Building metadata instead of rendering links? The pure `hreflangAlternates`
350
355
  (package root) returns the same links for use in `generateMetadata`:
351
356
  `{ canonical, links, languages }`, where `languages` is already keyed by
352
357
  hreflang for `Metadata.alternates.languages` (or `createMetadata`'s
353
358
  per-page `alternates`).
359
+
360
+ ## Verifying the cluster (`/verify`)
361
+
362
+ hreflang is a set of promises about **other** URLs, and nothing local can tell
363
+ you whether they hold. A site can emit a flawless cluster while its middleware
364
+ redirects every URL in it away, or while each variant quietly canonicalizes to
365
+ the default language. Both delete the non-default languages from search, neither
366
+ raises an error, and Search Console reports them as ordinary redirects and
367
+ duplicates months later. Fetch them:
368
+
369
+ ```ts
370
+ import { assertHreflangCluster } from "@ingram-tech/nk-seo/verify";
371
+ import { routing } from "@/lib/i18n/routing";
372
+
373
+ await assertHreflangCluster(routing, ["/", "/pricing", "/docs/getting-started"]);
374
+ ```
375
+
376
+ It fails on an advertised URL that redirects or isn't 200, a variant that
377
+ canonicalizes elsewhere, a missing or duplicated canonical, a non-reciprocal
378
+ cluster, and an `<html lang>` contradicting the `hreflang` it's advertised under.
379
+ `verifyHreflangCluster` returns the problems instead of throwing.
380
+
381
+ Run it against a real deployment — it is the only check that sees what a crawler
382
+ sees. A `LocaleRouting` from `@ingram-tech/nk-i18n` is a valid config for it.
@@ -30,11 +30,18 @@ export interface HreflangConfig {
30
30
  */
31
31
  prefixDefaultLocale?: boolean;
32
32
  /**
33
- * Locale of the page being rendered. Determines the self-referencing
34
- * canonical: a localized variant that canonicalizes to another URL makes
35
- * Google discard the entire hreflang cluster. For the `"prefix"` strategy it
36
- * is auto-detected from the pathname; for `"query"` the server can't see the
37
- * query string, so pass it (e.g. from your locale negotiation).
33
+ * The locale **the URL names**, which is not always the locale that rendered.
34
+ * It determines the self-referencing canonical, and a canonical is a claim
35
+ * about an address: under `"query"`, `/pricing` canonicalizes to `/pricing`
36
+ * even while content negotiation renders it in French for a French visitor,
37
+ * because `/pricing` is the negotiating entry point and belongs to no locale.
38
+ * Passing the negotiated locale here instead makes the bare path claim to be
39
+ * the French URL, and the real French URL then looks like a duplicate.
40
+ *
41
+ * Leave it unset on a negotiating bare path. Under `"prefix"` it is detected
42
+ * from the pathname; under `"query"` the server cannot see the query string,
43
+ * so pass it — `hreflangConfigFor(routing)` from
44
+ * "@ingram-tech/nk-i18n/next" fills it in correctly.
38
45
  */
39
46
  currentLocale?: string;
40
47
  /** Optional locale → hreflang tag map, e.g. `{ en: "en-BE", fr: "fr-BE" }`. */
@@ -1 +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;;;;;;;OAOG;IACH,QAAQ,CAAC,EAAE,OAAO,GAAG,QAAQ,CAAC;IAC9B,mEAAmE;IACnE,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,mFAAmF;IACnF,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;;;;OAMG;IACH,mBAAmB,CAAC,EAAE,OAAO,CAAC;IAC9B;;;;;;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,0DAA0D;IAC1D,KAAK,EAAE,YAAY,EAAE,CAAC;IACtB;;;;OAIG;IACH,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAClC;AAED;;;GAGG;AACH,wBAAgB,kBAAkB,CACjC,MAAM,EAAE,cAAc,EACtB,QAAQ,EAAE,MAAM,GACd,kBAAkB,CA8EpB"}
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;;;;;;;OAOG;IACH,QAAQ,CAAC,EAAE,OAAO,GAAG,QAAQ,CAAC;IAC9B,mEAAmE;IACnE,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,mFAAmF;IACnF,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;;;;OAMG;IACH,mBAAmB,CAAC,EAAE,OAAO,CAAC;IAC9B;;;;;;;;;;;;;OAaG;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,0DAA0D;IAC1D,KAAK,EAAE,YAAY,EAAE,CAAC;IACtB;;;;OAIG;IACH,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAClC;AAED;;;GAGG;AACH,wBAAgB,kBAAkB,CACjC,MAAM,EAAE,cAAc,EACtB,QAAQ,EAAE,MAAM,GACd,kBAAkB,CAuFpB"}
@@ -46,9 +46,18 @@ export function hreflangAlternates(config, pathname) {
46
46
  // Self-referencing canonical: the current variant's own URL. Canonicalizing
47
47
  // a localized variant to the bare path makes Google treat the variants as
48
48
  // duplicates and ignore the hreflang annotations entirely.
49
+ //
50
+ // Whether the default locale has a URL of its own is strategy-dependent, and
51
+ // conflating the two is a silent way to delete the default locale from the
52
+ // cluster. Under `"prefix"` it shares the bare path (unless every locale is
53
+ // prefixed), so its canonical IS the bare path. Under `"query"` every locale
54
+ // gets its own `?param=` address and the bare path belongs to none of them:
55
+ // it is the negotiating entry point that `x-default` names. So `?hl=en` must
56
+ // canonicalize to `?hl=en`, never to the bare path.
57
+ const defaultLocaleOwnsBarePath = strategy === "prefix" && !prefixDefaultLocale;
49
58
  const canonical = currentLocale &&
50
- (currentLocale !== defaultLocale || prefixDefaultLocale) &&
51
- config.locales.includes(currentLocale)
59
+ config.locales.includes(currentLocale) &&
60
+ !(defaultLocaleOwnsBarePath && currentLocale === defaultLocale)
52
61
  ? hrefFor(currentLocale)
53
62
  : defaultUrl;
54
63
  const links = [
@@ -1 +1 @@
1
- {"version":3,"file":"alternates.js","sourceRoot":"","sources":["../src/alternates.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"alternates.js","sourceRoot":"","sources":["../src/alternates.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Runtime verification that a site actually serves the hreflang cluster it
3
+ * advertises. Point it at a running deployment from CI or a test.
4
+ *
5
+ * hreflang is an unusual kind of markup: it is a set of promises about OTHER
6
+ * URLs, and nothing local can tell you whether those promises hold. A site can
7
+ * emit a flawless cluster while its middleware redirects every URL in it away,
8
+ * or while each variant quietly canonicalizes to the default language. Both
9
+ * delete the non-default languages from search, neither raises an error, and
10
+ * Search Console reports them as ordinary redirects and duplicates months
11
+ * later. The only way to know is to fetch the URLs and look.
12
+ */
13
+ import { type HreflangConfig } from "./alternates.js";
14
+ export interface HreflangProblem {
15
+ /** The advertised URL the problem was found at. */
16
+ url: string;
17
+ /** The page whose cluster advertises it. */
18
+ pathname: string;
19
+ problem: string;
20
+ }
21
+ export interface VerifyHreflangOptions {
22
+ /** Injectable for tests. Defaults to the global `fetch`. */
23
+ fetch?: typeof globalThis.fetch;
24
+ /**
25
+ * Also require `<html lang>` to match the locale the URL names. Default
26
+ * `true`. Turn it off only if the attribute carries a regional tag the
27
+ * cluster doesn't (e.g. `lang="fr-BE"` with `hreflang="fr"`).
28
+ */
29
+ checkHtmlLang?: boolean;
30
+ }
31
+ /**
32
+ * Fetch every URL the cluster for each pathname advertises and report what
33
+ * doesn't hold. An empty array means the cluster is sound.
34
+ *
35
+ * Checks, per advertised URL:
36
+ * - it returns 200 and is not a redirect (an annotated URL that 3xx's is the
37
+ * single most common way to lose a language);
38
+ * - its canonical points at itself, not at the default language;
39
+ * - it advertises the whole cluster back (Google discards non-reciprocal
40
+ * annotations, silently);
41
+ * - `<html lang>` matches the locale the URL names.
42
+ */
43
+ export declare function verifyHreflangCluster(config: HreflangConfig, pathnames: readonly string[], options?: VerifyHreflangOptions): Promise<HreflangProblem[]>;
44
+ /**
45
+ * {@link verifyHreflangCluster}, but throws a single readable error listing every
46
+ * problem. The shape to call from a test or a CI step.
47
+ */
48
+ export declare function assertHreflangCluster(config: HreflangConfig, pathnames: readonly string[], options?: VerifyHreflangOptions): Promise<void>;
49
+ //# sourceMappingURL=verify.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"verify.d.ts","sourceRoot":"","sources":["../src/verify.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,OAAO,EAAsB,KAAK,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAE1E,MAAM,WAAW,eAAe;IAC/B,mDAAmD;IACnD,GAAG,EAAE,MAAM,CAAC;IACZ,4CAA4C;IAC5C,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,qBAAqB;IACrC,4DAA4D;IAC5D,KAAK,CAAC,EAAE,OAAO,UAAU,CAAC,KAAK,CAAC;IAChC;;;;OAIG;IACH,aAAa,CAAC,EAAE,OAAO,CAAC;CACxB;AAgCD;;;;;;;;;;;GAWG;AACH,wBAAsB,qBAAqB,CAC1C,MAAM,EAAE,cAAc,EACtB,SAAS,EAAE,SAAS,MAAM,EAAE,EAC5B,OAAO,GAAE,qBAA0B,GACjC,OAAO,CAAC,eAAe,EAAE,CAAC,CAkG5B;AAED;;;GAGG;AACH,wBAAsB,qBAAqB,CAC1C,MAAM,EAAE,cAAc,EACtB,SAAS,EAAE,SAAS,MAAM,EAAE,EAC5B,OAAO,CAAC,EAAE,qBAAqB,GAC7B,OAAO,CAAC,IAAI,CAAC,CAOf"}
package/dist/verify.js ADDED
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Runtime verification that a site actually serves the hreflang cluster it
3
+ * advertises. Point it at a running deployment from CI or a test.
4
+ *
5
+ * hreflang is an unusual kind of markup: it is a set of promises about OTHER
6
+ * URLs, and nothing local can tell you whether those promises hold. A site can
7
+ * emit a flawless cluster while its middleware redirects every URL in it away,
8
+ * or while each variant quietly canonicalizes to the default language. Both
9
+ * delete the non-default languages from search, neither raises an error, and
10
+ * Search Console reports them as ordinary redirects and duplicates months
11
+ * later. The only way to know is to fetch the URLs and look.
12
+ */
13
+ import { hreflangAlternates } from "./alternates.js";
14
+ const decode = (value) => value
15
+ .replace(/&amp;/g, "&")
16
+ .replace(/&lt;/g, "<")
17
+ .replace(/&gt;/g, ">")
18
+ .replace(/&quot;/g, '"')
19
+ .replace(/&#0?39;|&apos;/g, "'");
20
+ const parseAttrs = (tag) => {
21
+ const attrs = {};
22
+ const pattern = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/g;
23
+ let match = pattern.exec(tag);
24
+ while (match !== null) {
25
+ const key = match[1]?.toLowerCase();
26
+ const value = match[2] ?? match[3] ?? match[4] ?? "";
27
+ if (key)
28
+ attrs[key] = decode(value);
29
+ match = pattern.exec(tag);
30
+ }
31
+ return attrs;
32
+ };
33
+ const tagsNamed = (html, name) => {
34
+ const pattern = new RegExp(`<${name}\\b[^>]*>`, "gi");
35
+ return (html.match(pattern) ?? []).map(parseAttrs);
36
+ };
37
+ /**
38
+ * Fetch every URL the cluster for each pathname advertises and report what
39
+ * doesn't hold. An empty array means the cluster is sound.
40
+ *
41
+ * Checks, per advertised URL:
42
+ * - it returns 200 and is not a redirect (an annotated URL that 3xx's is the
43
+ * single most common way to lose a language);
44
+ * - its canonical points at itself, not at the default language;
45
+ * - it advertises the whole cluster back (Google discards non-reciprocal
46
+ * annotations, silently);
47
+ * - `<html lang>` matches the locale the URL names.
48
+ */
49
+ export async function verifyHreflangCluster(config, pathnames, options = {}) {
50
+ const doFetch = options.fetch ?? globalThis.fetch;
51
+ const checkHtmlLang = options.checkHtmlLang ?? true;
52
+ const problems = [];
53
+ for (const pathname of pathnames) {
54
+ const { links } = hreflangAlternates(config, pathname);
55
+ const expected = links.map((link) => link.href);
56
+ // Map each advertised URL back to the locale it names. `links` is the
57
+ // locale list in order, then x-default; a real locale wins when the two
58
+ // share a URL (the prefix strategy's default locale does).
59
+ const localeOf = new Map();
60
+ links.forEach((link, index) => {
61
+ const locale = config.locales[index];
62
+ if (locale === undefined) {
63
+ if (!localeOf.has(link.href))
64
+ localeOf.set(link.href, undefined);
65
+ return;
66
+ }
67
+ localeOf.set(link.href, locale);
68
+ });
69
+ for (const [url, locale] of localeOf) {
70
+ const add = (problem) => {
71
+ problems.push({ url, pathname, problem });
72
+ };
73
+ let response;
74
+ try {
75
+ response = await doFetch(url, { redirect: "manual" });
76
+ }
77
+ catch (error) {
78
+ add(`request failed: ${error instanceof Error ? error.message : error}`);
79
+ continue;
80
+ }
81
+ if (response.status >= 300 && response.status < 400) {
82
+ const target = response.headers.get("location") ?? "(no location)";
83
+ add(`advertised in hreflang but redirects (${response.status} → ${target}); ` +
84
+ "an annotated URL must serve its language with a 200");
85
+ continue;
86
+ }
87
+ if (response.status !== 200) {
88
+ add(`advertised in hreflang but returned ${response.status}`);
89
+ continue;
90
+ }
91
+ const html = await response.text();
92
+ const canonicals = tagsNamed(html, "link")
93
+ .filter((attrs) => attrs.rel?.toLowerCase() === "canonical")
94
+ .map((attrs) => attrs.href)
95
+ .filter((href) => href !== undefined);
96
+ if (canonicals.length === 0) {
97
+ add("no <link rel=canonical>");
98
+ }
99
+ else if (canonicals.length > 1) {
100
+ add(`${canonicals.length} <link rel=canonical> tags; Google ignores all of them`);
101
+ }
102
+ else if (canonicals[0] !== url) {
103
+ add(`canonical points at ${canonicals[0]}, not itself; ` +
104
+ "a variant that canonicalizes elsewhere is discarded");
105
+ }
106
+ const advertised = new Set(tagsNamed(html, "link")
107
+ .filter((attrs) => attrs.rel?.toLowerCase() === "alternate" && attrs.hreflang)
108
+ .map((attrs) => attrs.href)
109
+ .filter((href) => href !== undefined));
110
+ const missing = expected.filter((href) => !advertised.has(href));
111
+ if (missing.length > 0) {
112
+ add(`does not link back to ${missing.join(", ")}; ` +
113
+ "hreflang must be reciprocal or Google drops the cluster");
114
+ }
115
+ if (checkHtmlLang && locale !== undefined) {
116
+ const lang = tagsNamed(html, "html")[0]?.lang;
117
+ if (lang !== locale) {
118
+ add(`serves <html lang="${lang ?? ""}"> but is advertised as "${locale}"`);
119
+ }
120
+ }
121
+ }
122
+ }
123
+ return problems;
124
+ }
125
+ /**
126
+ * {@link verifyHreflangCluster}, but throws a single readable error listing every
127
+ * problem. The shape to call from a test or a CI step.
128
+ */
129
+ export async function assertHreflangCluster(config, pathnames, options) {
130
+ const problems = await verifyHreflangCluster(config, pathnames, options);
131
+ if (problems.length === 0)
132
+ return;
133
+ const lines = problems.map((p) => ` ${p.url}\n ${p.problem}`);
134
+ throw new Error(`@ingram-tech/nk-seo: ${problems.length} hreflang problem(s):\n${lines.join("\n")}`);
135
+ }
136
+ //# sourceMappingURL=verify.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"verify.js","sourceRoot":"","sources":["../src/verify.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ingram-tech/nk-seo",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "SEO primitives for Next.js sites: typed schema.org JSON-LD builders, a <JsonLd> tag, a Metadata factory (canonical + OG + Twitter), and configurable hreflang alternates.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -28,6 +28,10 @@
28
28
  "./og": {
29
29
  "types": "./dist/og.d.ts",
30
30
  "import": "./dist/og.js"
31
+ },
32
+ "./verify": {
33
+ "types": "./dist/verify.d.ts",
34
+ "import": "./dist/verify.js"
31
35
  }
32
36
  },
33
37
  "scripts": {
@@ -36,13 +40,14 @@
36
40
  "test": "vitest run"
37
41
  },
38
42
  "devDependencies": {
39
- "@ingram-tech/nk-dev": "0.10.0",
40
- "@types/node": "^26.1.2",
43
+ "@ingram-tech/nk-dev": "0.11.1",
44
+ "@types/node": "^26.2.0",
41
45
  "@types/react": "^19.2.18",
42
- "next": "^16.2.12",
46
+ "next": "^16.3.1",
43
47
  "react": "^19.2.8",
44
48
  "typescript": "^7.0.2",
45
- "vitest": "^4.1.10"
49
+ "vitest": "^4.1.10",
50
+ "@ingram-tech/nk-i18n": "0.4.0"
46
51
  },
47
52
  "peerDependencies": {
48
53
  "next": ">=14.0.0",