@ingram-tech/nk-seo 0.6.2 → 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 +160 -16
- package/dist/alternates.d.ts +31 -8
- package/dist/alternates.d.ts.map +1 -1
- package/dist/alternates.js +36 -19
- package/dist/alternates.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/metadata.d.ts +55 -1
- package/dist/metadata.d.ts.map +1 -1
- package/dist/metadata.js +31 -2
- package/dist/metadata.js.map +1 -1
- package/dist/schema.d.ts +136 -12
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +111 -3
- package/dist/schema.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 +12 -7
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.
|
|
41
|
-
"@types/react": "^19.2.
|
|
42
|
-
"next": "^16.
|
|
43
|
-
"react": "^19.2.
|
|
43
|
+
"@ingram-tech/nk-dev": "0.11.1",
|
|
44
|
+
"@types/node": "^26.2.0",
|
|
45
|
+
"@types/react": "^19.2.18",
|
|
46
|
+
"next": "^16.3.1",
|
|
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",
|