@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 +34 -5
- package/dist/alternates.d.ts +12 -5
- package/dist/alternates.d.ts.map +1 -1
- package/dist/alternates.js +11 -2
- package/dist/alternates.js.map +1 -1
- package/dist/verify.d.ts +49 -0
- package/dist/verify.d.ts.map +1 -0
- package/dist/verify.js +136 -0
- package/dist/verify.js.map +1 -0
- package/package.json +10 -5
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
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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.
|
package/dist/alternates.d.ts
CHANGED
|
@@ -30,11 +30,18 @@ export interface HreflangConfig {
|
|
|
30
30
|
*/
|
|
31
31
|
prefixDefaultLocale?: boolean;
|
|
32
32
|
/**
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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" }`. */
|
package/dist/alternates.d.ts.map
CHANGED
|
@@ -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
|
|
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"}
|
package/dist/alternates.js
CHANGED
|
@@ -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
|
|
51
|
-
|
|
59
|
+
config.locales.includes(currentLocale) &&
|
|
60
|
+
!(defaultLocaleOwnsBarePath && currentLocale === defaultLocale)
|
|
52
61
|
? hrefFor(currentLocale)
|
|
53
62
|
: defaultUrl;
|
|
54
63
|
const links = [
|
package/dist/alternates.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"alternates.js","sourceRoot":"","sources":["../src/alternates.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;
|
|
1
|
+
{"version":3,"file":"alternates.js","sourceRoot":"","sources":["../src/alternates.ts"],"names":[],"mappings":"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"}
|
package/dist/verify.d.ts
ADDED
|
@@ -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(/&/g, "&")
|
|
16
|
+
.replace(/</g, "<")
|
|
17
|
+
.replace(/>/g, ">")
|
|
18
|
+
.replace(/"/g, '"')
|
|
19
|
+
.replace(/�?39;|'/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.
|
|
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.
|
|
40
|
-
"@types/node": "^26.
|
|
43
|
+
"@ingram-tech/nk-dev": "0.11.1",
|
|
44
|
+
"@types/node": "^26.2.0",
|
|
41
45
|
"@types/react": "^19.2.18",
|
|
42
|
-
"next": "^16.
|
|
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",
|