@mannisto/astro-metadata 1.0.2 → 2.0.0-alpha.1
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 +427 -82
- package/package.json +25 -14
- package/src/assets/icons/LICENSE.md +35 -0
- package/src/assets/icons/binoculars.svg +1 -0
- package/src/assets/icons/caret-right.svg +1 -0
- package/src/assets/icons/check.svg +1 -0
- package/src/assets/icons/copy.svg +1 -0
- package/src/assets/icons/seal-check-duotone.svg +1 -0
- package/src/assets/icons/seal-question-duotone.svg +1 -0
- package/src/assets/icons/warning-circle-duotone.svg +1 -0
- package/src/assets/icons/warning-duotone.svg +1 -0
- package/src/assets/icons/x.svg +1 -0
- package/src/components/Metadata.astro +145 -0
- package/src/components/index.ts +2 -0
- package/src/integration.d.ts +112 -0
- package/src/integration.js +508 -0
- package/src/lib/checks.d.ts +70 -0
- package/src/lib/checks.js +193 -0
- package/src/lib/config.ts +111 -0
- package/src/lib/favicon-files.d.ts +29 -0
- package/src/lib/favicon-files.js +103 -0
- package/src/lib/favicon-image.ts +124 -0
- package/src/lib/favicon.ts +167 -0
- package/src/lib/head.d.ts +25 -0
- package/src/lib/head.js +141 -0
- package/src/lib/manifest.ts +126 -0
- package/src/lib/rules.d.ts +73 -0
- package/src/lib/rules.js +902 -0
- package/src/lib/schema.ts +208 -0
- package/src/lib/site.ts +72 -0
- package/src/lib/url.ts +79 -0
- package/src/routes/favicon.ts +14 -0
- package/src/routes/manifest.ts +18 -0
- package/src/runtime.ts +16 -0
- package/src/toolbar/app.ts +72 -0
- package/src/toolbar/assets.ts +81 -0
- package/src/toolbar/data.ts +282 -0
- package/src/toolbar/define.ts +7 -0
- package/src/toolbar/env.d.ts +10 -0
- package/src/toolbar/highlight.ts +83 -0
- package/src/toolbar/styles/shared.css +18 -0
- package/src/toolbar/styles/tokens.css +182 -0
- package/src/toolbar/toolbar-badge.ts +78 -0
- package/src/toolbar/ui/alert/alert.css +126 -0
- package/src/toolbar/ui/alert/alert.ts +102 -0
- package/src/toolbar/ui/badge/badge.css +41 -0
- package/src/toolbar/ui/badge/badge.ts +24 -0
- package/src/toolbar/ui/code-block/code-block.css +90 -0
- package/src/toolbar/ui/code-block/code-block.ts +68 -0
- package/src/toolbar/ui/color-swatch/color-swatch.css +15 -0
- package/src/toolbar/ui/color-swatch/color-swatch.ts +31 -0
- package/src/toolbar/ui/icon/icon.css +11 -0
- package/src/toolbar/ui/icon/icon.ts +46 -0
- package/src/toolbar/ui/image/image.css +18 -0
- package/src/toolbar/ui/image/image.ts +58 -0
- package/src/toolbar/ui/inline-code/inline-code.css +16 -0
- package/src/toolbar/ui/inline-code/inline-code.ts +41 -0
- package/src/toolbar/ui/section/section.css +88 -0
- package/src/toolbar/ui/section/section.ts +78 -0
- package/src/toolbar/ui/tab/tab.css +7 -0
- package/src/toolbar/ui/tab/tab.ts +47 -0
- package/src/toolbar/ui/tab-group/tab-group.css +42 -0
- package/src/toolbar/ui/tab-group/tab-group.ts +101 -0
- package/src/toolbar/ui/table/table.css +124 -0
- package/src/toolbar/ui/table/table.ts +155 -0
- package/src/toolbar/views/code-blocks.ts +35 -0
- package/src/toolbar/views/metadata-panel.css +101 -0
- package/src/toolbar/views/metadata-panel.ts +125 -0
- package/src/toolbar/views/overview-view.css +27 -0
- package/src/toolbar/views/overview-view.ts +401 -0
- package/src/toolbar/views/previews/discord.css +70 -0
- package/src/toolbar/views/previews/discord.ts +60 -0
- package/src/toolbar/views/previews/facebook.css +65 -0
- package/src/toolbar/views/previews/facebook.ts +36 -0
- package/src/toolbar/views/previews/google.css +75 -0
- package/src/toolbar/views/previews/google.ts +36 -0
- package/src/toolbar/views/previews/linkedin.css +58 -0
- package/src/toolbar/views/previews/linkedin.ts +34 -0
- package/src/toolbar/views/previews/page.ts +129 -0
- package/src/toolbar/views/previews/platform.ts +14 -0
- package/src/toolbar/views/previews/previews-view.css +37 -0
- package/src/toolbar/views/previews/previews-view.ts +52 -0
- package/src/toolbar/views/previews/slack.css +61 -0
- package/src/toolbar/views/previews/slack.ts +47 -0
- package/src/toolbar/views/previews/twitter.css +89 -0
- package/src/toolbar/views/previews/twitter.ts +57 -0
- package/src/toolbar/views/previews/whatsapp.css +88 -0
- package/src/toolbar/views/previews/whatsapp.ts +65 -0
- package/src/toolbar/views/raw-view.ts +41 -0
- package/index.ts +0 -31
- package/src/components/Canonical.astro +0 -11
- package/src/components/Description.astro +0 -9
- package/src/components/Favicon.astro +0 -96
- package/src/components/Head.astro +0 -94
- package/src/components/Keywords.astro +0 -9
- package/src/components/LanguageAlternates.astro +0 -18
- package/src/components/OpenGraph.astro +0 -75
- package/src/components/Robots.astro +0 -25
- package/src/components/Schema.astro +0 -18
- package/src/components/Title.astro +0 -13
- package/src/components/Twitter.astro +0 -27
- package/src/lib/metadata.ts +0 -77
package/src/lib/rules.js
ADDED
|
@@ -0,0 +1,902 @@
|
|
|
1
|
+
// Plain JavaScript: the integration runs in Node, which does not strip types in node_modules.
|
|
2
|
+
//
|
|
3
|
+
// The rules of the metadata checks. The build and the dev toolbar app use the same rules.
|
|
4
|
+
// To add a rule, add an object to the list below:
|
|
5
|
+
//
|
|
6
|
+
// {
|
|
7
|
+
// id: "og-title-missing", // a short, unique name: users turn it off with `rules`
|
|
8
|
+
// field: "og:title", // the tag of the problem: the toolbar app shows its row
|
|
9
|
+
// level: "warning", // "error", "warning" or "info"
|
|
10
|
+
// indexed: true, // optional: skip the rule on a noindex page
|
|
11
|
+
// check(head) { // returns the message of a problem, or undefined
|
|
12
|
+
// if (!head.meta["og:title"]) return "The page has no `og:title`."
|
|
13
|
+
// },
|
|
14
|
+
// }
|
|
15
|
+
//
|
|
16
|
+
// Text between backticks shows as code in the dev toolbar app.
|
|
17
|
+
|
|
18
|
+
import { findLink, findLinks, isAbsolute } from "./head.js"
|
|
19
|
+
|
|
20
|
+
/** The approximate lengths that search results show. */
|
|
21
|
+
export const LIMITS = {
|
|
22
|
+
title: 60,
|
|
23
|
+
description: 160,
|
|
24
|
+
/** A shorter description gives search engines little to show. */
|
|
25
|
+
shortDescription: 50,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// The image sizes of the social sites, in px
|
|
29
|
+
const IMAGE = {
|
|
30
|
+
/** All sites show a large image from this size. */
|
|
31
|
+
large: { width: 1200, height: 630 },
|
|
32
|
+
/** Facebook shows a small image under this size. */
|
|
33
|
+
small: { width: 600, height: 315 },
|
|
34
|
+
/** Facebook shows no image under this size. */
|
|
35
|
+
minimum: 200,
|
|
36
|
+
/** The ratios that the social sites show without a large crop. */
|
|
37
|
+
ratio: { min: 1.6, max: 2.1 },
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const TWITTER_CARDS = ["summary", "summary_large_image", "app", "player"]
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Returns "1 title tag" or "2 title tags".
|
|
44
|
+
*
|
|
45
|
+
* @param {number} count
|
|
46
|
+
* @param {string} word
|
|
47
|
+
* @returns {string}
|
|
48
|
+
*/
|
|
49
|
+
function count(count, word) {
|
|
50
|
+
return `${count} ${word}${count === 1 ? "" : "s"}`
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Returns the JSON of each valid JSON-LD script, and the number of invalid scripts.
|
|
55
|
+
*
|
|
56
|
+
* @param {import("./head").HeadData} head
|
|
57
|
+
* @returns {{ valid: unknown[], invalid: number }}
|
|
58
|
+
*/
|
|
59
|
+
function parseSchemas(head) {
|
|
60
|
+
const valid = []
|
|
61
|
+
let invalid = 0
|
|
62
|
+
for (const schema of head.schemas) {
|
|
63
|
+
try {
|
|
64
|
+
valid.push(JSON.parse(schema))
|
|
65
|
+
} catch {
|
|
66
|
+
invalid++
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return { valid, invalid }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Returns the `og:image` size, or undefined without the width and the height.
|
|
74
|
+
*
|
|
75
|
+
* @param {import("./head").HeadData} head
|
|
76
|
+
* @returns {{ width: number, height: number } | undefined}
|
|
77
|
+
*/
|
|
78
|
+
function imageSize(head) {
|
|
79
|
+
const width = Number.parseInt(head.meta["og:image:width"] ?? "", 10)
|
|
80
|
+
const height = Number.parseInt(head.meta["og:image:height"] ?? "", 10)
|
|
81
|
+
return Number.isNaN(width) || Number.isNaN(height) ? undefined : { width, height }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Returns the language of a locale, e.g. "en" for "en-US" or "en_US".
|
|
86
|
+
*
|
|
87
|
+
* @param {string} locale
|
|
88
|
+
* @returns {string}
|
|
89
|
+
*/
|
|
90
|
+
function language(locale) {
|
|
91
|
+
return locale.split(/[-_]/)[0].toLowerCase()
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Returns the hreflang links of the page.
|
|
96
|
+
*
|
|
97
|
+
* @param {import("./head").HeadData} head
|
|
98
|
+
* @returns {Record<string, string>[]}
|
|
99
|
+
*/
|
|
100
|
+
function hreflangLinks(head) {
|
|
101
|
+
return findLinks(head, "alternate").filter((link) => {
|
|
102
|
+
return Boolean(link.hreflang)
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Text that a template leaves when a value is missing, e.g. "undefined | My Site"
|
|
107
|
+
const PLACEHOLDER = /\[object Object\]|%s|\{\{|\}\}/
|
|
108
|
+
const EMPTY_VALUES = ["undefined", "null", "nan"]
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Returns the first value that contains placeholder text, or undefined.
|
|
112
|
+
*
|
|
113
|
+
* @param {(string | undefined)[]} values
|
|
114
|
+
* @returns {string | undefined}
|
|
115
|
+
*/
|
|
116
|
+
function findPlaceholder(values) {
|
|
117
|
+
return values.find((value) => {
|
|
118
|
+
if (!value) return false
|
|
119
|
+
if (PLACEHOLDER.test(value)) return true
|
|
120
|
+
// A part of a title between separators, e.g. "undefined" in "undefined | My Site"
|
|
121
|
+
return value.split(/\s[|\-–—:·]\s/).some((part) => {
|
|
122
|
+
return EMPTY_VALUES.includes(part.trim().toLowerCase())
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// The fields that Google needs for a rich result. Without them, the type gives no rich result.
|
|
128
|
+
const REQUIRED_FIELDS = {
|
|
129
|
+
BreadcrumbList: ["itemListElement"],
|
|
130
|
+
Event: ["name", "startDate", "location"],
|
|
131
|
+
FAQPage: ["mainEntity"],
|
|
132
|
+
JobPosting: ["title", "datePosted", "description", "hiringOrganization"],
|
|
133
|
+
Product: ["name"],
|
|
134
|
+
Recipe: ["name", "image"],
|
|
135
|
+
Review: ["itemReviewed", "author"],
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// The fields that Google recommends for an article
|
|
139
|
+
const ARTICLE_TYPES = ["Article", "NewsArticle", "BlogPosting"]
|
|
140
|
+
const ARTICLE_FIELDS = ["headline", "image", "datePublished", "author"]
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Returns all items of the valid JSON-LD scripts: the items of arrays and of `@graph`.
|
|
144
|
+
*
|
|
145
|
+
* @param {import("./head").HeadData} head
|
|
146
|
+
* @returns {Record<string, unknown>[]}
|
|
147
|
+
*/
|
|
148
|
+
function schemaItems(head) {
|
|
149
|
+
return parseSchemas(head).valid.flatMap((schema) => {
|
|
150
|
+
const items = Array.isArray(schema) ? schema : [schema]
|
|
151
|
+
return items.flatMap((item) => {
|
|
152
|
+
if (!item || typeof item !== "object") return []
|
|
153
|
+
return Array.isArray(item["@graph"]) ? [item, ...item["@graph"]] : [item]
|
|
154
|
+
})
|
|
155
|
+
})
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Returns the types of a structured data item, e.g. ["Article"].
|
|
160
|
+
*
|
|
161
|
+
* @param {Record<string, unknown>} item
|
|
162
|
+
* @returns {string[]}
|
|
163
|
+
*/
|
|
164
|
+
function typesOf(item) {
|
|
165
|
+
const type = item["@type"]
|
|
166
|
+
return (Array.isArray(type) ? type : [type]).filter((value) => {
|
|
167
|
+
return typeof value === "string"
|
|
168
|
+
})
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Returns "`a`" or "`a` and `b`" or "`a`, `b` and `c`".
|
|
173
|
+
*
|
|
174
|
+
* @param {string[]} names
|
|
175
|
+
* @returns {string}
|
|
176
|
+
*/
|
|
177
|
+
function codeList(names) {
|
|
178
|
+
const codes = names.map((name) => {
|
|
179
|
+
return `\`${name}\``
|
|
180
|
+
})
|
|
181
|
+
if (codes.length === 1) return codes[0]
|
|
182
|
+
return `${codes.slice(0, -1).join(", ")} and ${codes[codes.length - 1]}`
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** @type {import("./rules").Rule[]} */
|
|
186
|
+
export const RULES = [
|
|
187
|
+
// The document
|
|
188
|
+
|
|
189
|
+
{
|
|
190
|
+
id: "title-missing",
|
|
191
|
+
field: "title",
|
|
192
|
+
level: "error",
|
|
193
|
+
check(head) {
|
|
194
|
+
if (!head.titles[0]) return "The page has no title."
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
id: "title-duplicate",
|
|
199
|
+
field: "title",
|
|
200
|
+
level: "error",
|
|
201
|
+
check(head) {
|
|
202
|
+
if (head.titles.length > 1) {
|
|
203
|
+
return `The page has ${count(head.titles.length, "title tag")}. Keep only one.`
|
|
204
|
+
}
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
id: "title-long",
|
|
209
|
+
field: "title",
|
|
210
|
+
level: "warning",
|
|
211
|
+
check(head) {
|
|
212
|
+
const title = head.titles[0] ?? ""
|
|
213
|
+
if (title.length > LIMITS.title) {
|
|
214
|
+
return `The title has ${title.length} characters. Search results show about ${LIMITS.title}.`
|
|
215
|
+
}
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
{
|
|
219
|
+
id: "charset-missing",
|
|
220
|
+
field: "charset",
|
|
221
|
+
level: "warning",
|
|
222
|
+
check(head) {
|
|
223
|
+
if (!head.charset)
|
|
224
|
+
return "The page has no `<meta charset>`. Browsers can show wrong characters."
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
id: "viewport-missing",
|
|
229
|
+
field: "viewport",
|
|
230
|
+
level: "warning",
|
|
231
|
+
check(head) {
|
|
232
|
+
if (!head.meta.viewport) {
|
|
233
|
+
return "The page has no `viewport` meta tag. Phones show the page zoomed out."
|
|
234
|
+
}
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
{
|
|
238
|
+
id: "lang-missing",
|
|
239
|
+
field: "lang",
|
|
240
|
+
level: "warning",
|
|
241
|
+
check(head) {
|
|
242
|
+
if (!head.lang) return "The `<html>` tag has no `lang` attribute."
|
|
243
|
+
},
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
id: "lang-invalid",
|
|
247
|
+
field: "lang",
|
|
248
|
+
level: "warning",
|
|
249
|
+
check(head) {
|
|
250
|
+
if (head.lang && !/^[a-z]{2,3}(-[a-z0-9]{2,8})*$/i.test(head.lang)) {
|
|
251
|
+
return `The \`lang\` value \`${head.lang}\` is not valid. Use a code like \`en\` or \`en-US\`.`
|
|
252
|
+
}
|
|
253
|
+
},
|
|
254
|
+
},
|
|
255
|
+
{
|
|
256
|
+
id: "placeholder-title",
|
|
257
|
+
field: "title",
|
|
258
|
+
level: "error",
|
|
259
|
+
check(head) {
|
|
260
|
+
const value = findPlaceholder([
|
|
261
|
+
head.titles[0],
|
|
262
|
+
head.meta["og:title"],
|
|
263
|
+
head.meta["twitter:title"],
|
|
264
|
+
])
|
|
265
|
+
if (value) return `The title contains placeholder text: "${value}".`
|
|
266
|
+
},
|
|
267
|
+
},
|
|
268
|
+
{
|
|
269
|
+
id: "placeholder-description",
|
|
270
|
+
field: "description",
|
|
271
|
+
level: "error",
|
|
272
|
+
check(head) {
|
|
273
|
+
const value = findPlaceholder([
|
|
274
|
+
head.meta.description,
|
|
275
|
+
head.meta["og:description"],
|
|
276
|
+
head.meta["twitter:description"],
|
|
277
|
+
])
|
|
278
|
+
if (value) return `The description contains placeholder text: "${value}".`
|
|
279
|
+
},
|
|
280
|
+
},
|
|
281
|
+
{
|
|
282
|
+
id: "favicon-missing",
|
|
283
|
+
field: "favicon",
|
|
284
|
+
level: "warning",
|
|
285
|
+
check(head) {
|
|
286
|
+
if (!findLink(head, "icon")) return "The page has no favicon link."
|
|
287
|
+
},
|
|
288
|
+
},
|
|
289
|
+
{
|
|
290
|
+
id: "apple-touch-icon-missing",
|
|
291
|
+
field: "favicon",
|
|
292
|
+
level: "info",
|
|
293
|
+
check(head) {
|
|
294
|
+
if (findLink(head, "icon") && !findLink(head, "apple-touch-icon")) {
|
|
295
|
+
return "The page has no `apple-touch-icon`. iOS shows a screenshot of the page on the home screen."
|
|
296
|
+
}
|
|
297
|
+
},
|
|
298
|
+
},
|
|
299
|
+
|
|
300
|
+
// Structured data
|
|
301
|
+
|
|
302
|
+
{
|
|
303
|
+
id: "json-ld-invalid",
|
|
304
|
+
field: "json-ld",
|
|
305
|
+
level: "error",
|
|
306
|
+
check(head) {
|
|
307
|
+
const { invalid } = parseSchemas(head)
|
|
308
|
+
if (invalid === 1) return "A JSON-LD script does not contain valid JSON."
|
|
309
|
+
if (invalid > 1) return `${invalid} JSON-LD scripts do not contain valid JSON.`
|
|
310
|
+
},
|
|
311
|
+
},
|
|
312
|
+
{
|
|
313
|
+
id: "json-ld-context",
|
|
314
|
+
field: "json-ld",
|
|
315
|
+
level: "warning",
|
|
316
|
+
check(head) {
|
|
317
|
+
const missing = parseSchemas(head).valid.some((schema) => {
|
|
318
|
+
const items = Array.isArray(schema) ? schema : [schema]
|
|
319
|
+
return items.some((item) => {
|
|
320
|
+
return item && typeof item === "object" && !("@context" in item)
|
|
321
|
+
})
|
|
322
|
+
})
|
|
323
|
+
if (missing) return "A JSON-LD item has no `@context`. Set it to `https://schema.org`."
|
|
324
|
+
},
|
|
325
|
+
},
|
|
326
|
+
{
|
|
327
|
+
id: "json-ld-required",
|
|
328
|
+
field: "json-ld",
|
|
329
|
+
level: "error",
|
|
330
|
+
check(head) {
|
|
331
|
+
for (const item of schemaItems(head)) {
|
|
332
|
+
for (const type of typesOf(item)) {
|
|
333
|
+
const missing = (REQUIRED_FIELDS[type] ?? []).filter((field) => {
|
|
334
|
+
return item[field] === undefined
|
|
335
|
+
})
|
|
336
|
+
if (missing.length > 0) {
|
|
337
|
+
return `The \`${type}\` structured data has no ${codeList(missing)}. Google shows no rich result.`
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
},
|
|
342
|
+
},
|
|
343
|
+
{
|
|
344
|
+
id: "json-ld-breadcrumb",
|
|
345
|
+
field: "json-ld",
|
|
346
|
+
level: "error",
|
|
347
|
+
check(head) {
|
|
348
|
+
const lists = schemaItems(head).filter((item) => {
|
|
349
|
+
return typesOf(item).includes("BreadcrumbList")
|
|
350
|
+
})
|
|
351
|
+
for (const list of lists) {
|
|
352
|
+
const items = Array.isArray(list.itemListElement) ? list.itemListElement : []
|
|
353
|
+
const invalid = items.some((item) => {
|
|
354
|
+
return !item || item.position === undefined || item.name === undefined
|
|
355
|
+
})
|
|
356
|
+
if (invalid) {
|
|
357
|
+
return "An item of the `BreadcrumbList` has no `position` or `name`."
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
},
|
|
361
|
+
},
|
|
362
|
+
{
|
|
363
|
+
id: "json-ld-article",
|
|
364
|
+
field: "json-ld",
|
|
365
|
+
level: "warning",
|
|
366
|
+
indexed: true,
|
|
367
|
+
check(head) {
|
|
368
|
+
for (const item of schemaItems(head)) {
|
|
369
|
+
const type = typesOf(item).find((value) => {
|
|
370
|
+
return ARTICLE_TYPES.includes(value)
|
|
371
|
+
})
|
|
372
|
+
if (!type) continue
|
|
373
|
+
|
|
374
|
+
const missing = ARTICLE_FIELDS.filter((field) => {
|
|
375
|
+
return item[field] === undefined
|
|
376
|
+
})
|
|
377
|
+
if (missing.length > 0) {
|
|
378
|
+
return `The \`${type}\` structured data has no ${codeList(missing)}. Google recommends them.`
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
},
|
|
382
|
+
},
|
|
383
|
+
|
|
384
|
+
// Search engines
|
|
385
|
+
|
|
386
|
+
{
|
|
387
|
+
id: "noindex",
|
|
388
|
+
field: "robots",
|
|
389
|
+
level: "info",
|
|
390
|
+
check(head) {
|
|
391
|
+
if (/noindex/i.test(head.meta.robots ?? "")) {
|
|
392
|
+
return "The robots tag contains `noindex`: search engines do not show the page."
|
|
393
|
+
}
|
|
394
|
+
},
|
|
395
|
+
},
|
|
396
|
+
{
|
|
397
|
+
id: "description-missing",
|
|
398
|
+
field: "description",
|
|
399
|
+
level: "warning",
|
|
400
|
+
indexed: true,
|
|
401
|
+
check(head) {
|
|
402
|
+
if (!head.meta.description) return "The page has no description."
|
|
403
|
+
},
|
|
404
|
+
},
|
|
405
|
+
{
|
|
406
|
+
id: "description-long",
|
|
407
|
+
field: "description",
|
|
408
|
+
level: "warning",
|
|
409
|
+
indexed: true,
|
|
410
|
+
check(head) {
|
|
411
|
+
const length = head.meta.description?.length ?? 0
|
|
412
|
+
if (length > LIMITS.description) {
|
|
413
|
+
return `The description has ${length} characters. Search results show about ${LIMITS.description}.`
|
|
414
|
+
}
|
|
415
|
+
},
|
|
416
|
+
},
|
|
417
|
+
{
|
|
418
|
+
id: "description-short",
|
|
419
|
+
field: "description",
|
|
420
|
+
level: "info",
|
|
421
|
+
indexed: true,
|
|
422
|
+
check(head) {
|
|
423
|
+
const length = head.meta.description?.length ?? 0
|
|
424
|
+
if (length > 0 && length < LIMITS.shortDescription) {
|
|
425
|
+
return `The description has ${length} characters. Use ${LIMITS.shortDescription} to ${LIMITS.description} characters.`
|
|
426
|
+
}
|
|
427
|
+
},
|
|
428
|
+
},
|
|
429
|
+
{
|
|
430
|
+
id: "description-duplicate",
|
|
431
|
+
field: "description",
|
|
432
|
+
level: "warning",
|
|
433
|
+
indexed: true,
|
|
434
|
+
check(head) {
|
|
435
|
+
const tags = head.metaCount.description ?? 0
|
|
436
|
+
if (tags > 1) return `The page has ${tags} description tags. Search engines use only one.`
|
|
437
|
+
},
|
|
438
|
+
},
|
|
439
|
+
{
|
|
440
|
+
id: "canonical-missing",
|
|
441
|
+
field: "canonical",
|
|
442
|
+
level: "warning",
|
|
443
|
+
indexed: true,
|
|
444
|
+
check(head) {
|
|
445
|
+
if (!findLink(head, "canonical")) return "The page has no canonical URL."
|
|
446
|
+
},
|
|
447
|
+
},
|
|
448
|
+
{
|
|
449
|
+
id: "canonical-duplicate",
|
|
450
|
+
field: "canonical",
|
|
451
|
+
level: "error",
|
|
452
|
+
indexed: true,
|
|
453
|
+
check(head) {
|
|
454
|
+
const links = findLinks(head, "canonical").length
|
|
455
|
+
if (links > 1) return `The page has ${links} canonical links. Keep only one.`
|
|
456
|
+
},
|
|
457
|
+
},
|
|
458
|
+
{
|
|
459
|
+
id: "canonical-relative",
|
|
460
|
+
field: "canonical",
|
|
461
|
+
level: "warning",
|
|
462
|
+
indexed: true,
|
|
463
|
+
check(head) {
|
|
464
|
+
const canonical = findLink(head, "canonical")
|
|
465
|
+
if (canonical && !isAbsolute(canonical)) {
|
|
466
|
+
return "The canonical URL is not absolute. Set `site` in the Astro config."
|
|
467
|
+
}
|
|
468
|
+
},
|
|
469
|
+
},
|
|
470
|
+
|
|
471
|
+
// Social sites
|
|
472
|
+
|
|
473
|
+
{
|
|
474
|
+
id: "og-title-missing",
|
|
475
|
+
field: "og:title",
|
|
476
|
+
level: "warning",
|
|
477
|
+
indexed: true,
|
|
478
|
+
check(head) {
|
|
479
|
+
if (!head.meta["og:title"])
|
|
480
|
+
return "The page has no `og:title`. Social sites use the `<title>`."
|
|
481
|
+
},
|
|
482
|
+
},
|
|
483
|
+
{
|
|
484
|
+
id: "og-description-missing",
|
|
485
|
+
field: "og:description",
|
|
486
|
+
level: "warning",
|
|
487
|
+
indexed: true,
|
|
488
|
+
check(head) {
|
|
489
|
+
if (!head.meta["og:description"]) {
|
|
490
|
+
return "The page has no `og:description`. Some social cards show no description."
|
|
491
|
+
}
|
|
492
|
+
},
|
|
493
|
+
},
|
|
494
|
+
{
|
|
495
|
+
id: "og-url-mismatch",
|
|
496
|
+
field: "og:url",
|
|
497
|
+
level: "warning",
|
|
498
|
+
indexed: true,
|
|
499
|
+
check(head) {
|
|
500
|
+
const url = head.meta["og:url"]
|
|
501
|
+
const canonical = findLink(head, "canonical")
|
|
502
|
+
if (url && canonical && url !== canonical) {
|
|
503
|
+
return "The `og:url` is not the same as the canonical URL. Social sites can count the shares two times."
|
|
504
|
+
}
|
|
505
|
+
},
|
|
506
|
+
},
|
|
507
|
+
{
|
|
508
|
+
id: "og-locale-mismatch",
|
|
509
|
+
field: "og:locale",
|
|
510
|
+
level: "warning",
|
|
511
|
+
indexed: true,
|
|
512
|
+
check(head) {
|
|
513
|
+
const locale = head.meta["og:locale"]
|
|
514
|
+
if (!locale || !head.lang) return
|
|
515
|
+
if (language(locale) !== language(head.lang)) {
|
|
516
|
+
return `The \`og:locale\` \`${locale}\` is not the language of the \`lang\` attribute \`${head.lang}\`.`
|
|
517
|
+
}
|
|
518
|
+
},
|
|
519
|
+
},
|
|
520
|
+
{
|
|
521
|
+
id: "og-image-missing",
|
|
522
|
+
field: "og:image",
|
|
523
|
+
level: "warning",
|
|
524
|
+
indexed: true,
|
|
525
|
+
check(head) {
|
|
526
|
+
if (!head.meta["og:image"]) return "The page has no `og:image`. Social cards show no image."
|
|
527
|
+
},
|
|
528
|
+
},
|
|
529
|
+
{
|
|
530
|
+
id: "og-image-relative",
|
|
531
|
+
field: "og:image",
|
|
532
|
+
level: "warning",
|
|
533
|
+
indexed: true,
|
|
534
|
+
check(head) {
|
|
535
|
+
const image = head.meta["og:image"]
|
|
536
|
+
if (image && !isAbsolute(image)) {
|
|
537
|
+
return "The `og:image` URL is not absolute. Social sites can not load it."
|
|
538
|
+
}
|
|
539
|
+
},
|
|
540
|
+
},
|
|
541
|
+
{
|
|
542
|
+
id: "og-image-http",
|
|
543
|
+
field: "og:image",
|
|
544
|
+
level: "warning",
|
|
545
|
+
indexed: true,
|
|
546
|
+
check(head) {
|
|
547
|
+
if (/^http:\/\//i.test(head.meta["og:image"] ?? "")) {
|
|
548
|
+
return "The `og:image` URL uses `http`. Some sites, e.g. WhatsApp, load only `https` images."
|
|
549
|
+
}
|
|
550
|
+
},
|
|
551
|
+
},
|
|
552
|
+
{
|
|
553
|
+
id: "og-image-size-missing",
|
|
554
|
+
field: "og:image",
|
|
555
|
+
level: "warning",
|
|
556
|
+
indexed: true,
|
|
557
|
+
check(head) {
|
|
558
|
+
if (head.meta["og:image"] && !imageSize(head)) {
|
|
559
|
+
return "The `og:image` has no width and height. The first share can show no image."
|
|
560
|
+
}
|
|
561
|
+
},
|
|
562
|
+
},
|
|
563
|
+
{
|
|
564
|
+
id: "og-image-too-small",
|
|
565
|
+
field: "og:image",
|
|
566
|
+
level: "error",
|
|
567
|
+
indexed: true,
|
|
568
|
+
check(head) {
|
|
569
|
+
const size = imageSize(head)
|
|
570
|
+
if (size && (size.width < IMAGE.minimum || size.height < IMAGE.minimum)) {
|
|
571
|
+
return `The \`og:image\` is ${size.width} × ${size.height} px. Facebook does not show images smaller than ${IMAGE.minimum} × ${IMAGE.minimum} px.`
|
|
572
|
+
}
|
|
573
|
+
},
|
|
574
|
+
},
|
|
575
|
+
{
|
|
576
|
+
id: "og-image-small",
|
|
577
|
+
field: "og:image",
|
|
578
|
+
level: "warning",
|
|
579
|
+
indexed: true,
|
|
580
|
+
check(head) {
|
|
581
|
+
const size = imageSize(head)
|
|
582
|
+
if (!size || size.width < IMAGE.minimum || size.height < IMAGE.minimum) return
|
|
583
|
+
if (size.width < IMAGE.small.width || size.height < IMAGE.small.height) {
|
|
584
|
+
return `The \`og:image\` is ${size.width} × ${size.height} px. Social sites show it small. Use ${IMAGE.large.width} × ${IMAGE.large.height} px.`
|
|
585
|
+
}
|
|
586
|
+
},
|
|
587
|
+
},
|
|
588
|
+
{
|
|
589
|
+
id: "og-image-ratio",
|
|
590
|
+
field: "og:image",
|
|
591
|
+
level: "warning",
|
|
592
|
+
indexed: true,
|
|
593
|
+
check(head) {
|
|
594
|
+
const size = imageSize(head)
|
|
595
|
+
// A small card can use a square image, and a too small image has its own error
|
|
596
|
+
if (!size || head.meta["twitter:card"] === "summary") return
|
|
597
|
+
if (size.width < IMAGE.minimum || size.height < IMAGE.minimum) return
|
|
598
|
+
|
|
599
|
+
const ratio = size.width / size.height
|
|
600
|
+
if (ratio < IMAGE.ratio.min || ratio > IMAGE.ratio.max) {
|
|
601
|
+
return `The \`og:image\` ratio is ${ratio.toFixed(2)}:1. Social sites crop it to about 1.91:1.`
|
|
602
|
+
}
|
|
603
|
+
},
|
|
604
|
+
},
|
|
605
|
+
{
|
|
606
|
+
id: "og-image-alt-missing",
|
|
607
|
+
field: "og:image:alt",
|
|
608
|
+
level: "info",
|
|
609
|
+
indexed: true,
|
|
610
|
+
check(head) {
|
|
611
|
+
if (head.meta["og:image"] && !head.meta["og:image:alt"]) {
|
|
612
|
+
return "The `og:image` has no `og:image:alt`. Screen readers can not describe the image."
|
|
613
|
+
}
|
|
614
|
+
},
|
|
615
|
+
},
|
|
616
|
+
{
|
|
617
|
+
id: "twitter-card-missing",
|
|
618
|
+
field: "twitter:card",
|
|
619
|
+
level: "warning",
|
|
620
|
+
indexed: true,
|
|
621
|
+
check(head) {
|
|
622
|
+
if (!head.meta["twitter:card"])
|
|
623
|
+
return "The page has no `twitter:card`. Twitter (X) shows no card."
|
|
624
|
+
},
|
|
625
|
+
},
|
|
626
|
+
{
|
|
627
|
+
id: "twitter-card-invalid",
|
|
628
|
+
field: "twitter:card",
|
|
629
|
+
level: "error",
|
|
630
|
+
indexed: true,
|
|
631
|
+
check(head) {
|
|
632
|
+
const card = head.meta["twitter:card"]
|
|
633
|
+
if (card && !TWITTER_CARDS.includes(card)) {
|
|
634
|
+
return `The \`twitter:card\` value \`${card}\` is not valid. Use \`summary\` or \`summary_large_image\`.`
|
|
635
|
+
}
|
|
636
|
+
},
|
|
637
|
+
},
|
|
638
|
+
|
|
639
|
+
// Locales
|
|
640
|
+
|
|
641
|
+
{
|
|
642
|
+
id: "hreflang-x-default",
|
|
643
|
+
field: "hreflang",
|
|
644
|
+
level: "warning",
|
|
645
|
+
indexed: true,
|
|
646
|
+
check(head) {
|
|
647
|
+
const links = hreflangLinks(head)
|
|
648
|
+
const fallback = links.some((link) => {
|
|
649
|
+
return link.hreflang.toLowerCase() === "x-default"
|
|
650
|
+
})
|
|
651
|
+
if (links.length > 0 && !fallback) {
|
|
652
|
+
return "The hreflang links have no `x-default`. Add it for the users of other locales."
|
|
653
|
+
}
|
|
654
|
+
},
|
|
655
|
+
},
|
|
656
|
+
{
|
|
657
|
+
id: "hreflang-self",
|
|
658
|
+
field: "hreflang",
|
|
659
|
+
level: "warning",
|
|
660
|
+
indexed: true,
|
|
661
|
+
check(head) {
|
|
662
|
+
const links = hreflangLinks(head)
|
|
663
|
+
const canonical = findLink(head, "canonical")
|
|
664
|
+
const self = links.some((link) => {
|
|
665
|
+
return link.href === canonical
|
|
666
|
+
})
|
|
667
|
+
if (links.length > 0 && canonical && !self) {
|
|
668
|
+
return "The hreflang links do not include this page. Each locale version must also link to itself."
|
|
669
|
+
}
|
|
670
|
+
},
|
|
671
|
+
},
|
|
672
|
+
{
|
|
673
|
+
id: "hreflang-relative",
|
|
674
|
+
field: "hreflang",
|
|
675
|
+
level: "warning",
|
|
676
|
+
indexed: true,
|
|
677
|
+
check(head) {
|
|
678
|
+
const relative = hreflangLinks(head).some((link) => {
|
|
679
|
+
return !isAbsolute(link.href ?? "")
|
|
680
|
+
})
|
|
681
|
+
if (relative) return "An hreflang URL is not absolute. Search engines need absolute URLs."
|
|
682
|
+
},
|
|
683
|
+
},
|
|
684
|
+
{
|
|
685
|
+
id: "hreflang-duplicate",
|
|
686
|
+
field: "hreflang",
|
|
687
|
+
level: "warning",
|
|
688
|
+
indexed: true,
|
|
689
|
+
check(head) {
|
|
690
|
+
const locales = hreflangLinks(head).map((link) => {
|
|
691
|
+
return link.hreflang.toLowerCase()
|
|
692
|
+
})
|
|
693
|
+
const duplicate = locales.find((locale, index) => {
|
|
694
|
+
return locales.indexOf(locale) !== index
|
|
695
|
+
})
|
|
696
|
+
if (duplicate) {
|
|
697
|
+
return `The hreflang \`${duplicate}\` is on more than one link. Keep one link for each locale.`
|
|
698
|
+
}
|
|
699
|
+
},
|
|
700
|
+
},
|
|
701
|
+
]
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* Returns the pages that search engines index: the pages without `noindex`.
|
|
705
|
+
*
|
|
706
|
+
* @param {import("./rules").SitePage[]} pages
|
|
707
|
+
* @returns {import("./rules").SitePage[]}
|
|
708
|
+
*/
|
|
709
|
+
function indexedPages(pages) {
|
|
710
|
+
return pages.filter((page) => {
|
|
711
|
+
return !/noindex/i.test(page.head.meta.robots ?? "")
|
|
712
|
+
})
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/**
|
|
716
|
+
* Returns a problem for each page with a value that other pages also have.
|
|
717
|
+
*
|
|
718
|
+
* @param {import("./rules").SitePage[]} pages
|
|
719
|
+
* @param {(page: import("./rules").SitePage) => string | undefined} valueOf
|
|
720
|
+
* @param {string} name - The name of the value in the message, e.g. "title".
|
|
721
|
+
* @returns {import("./rules").SiteProblem[]}
|
|
722
|
+
*/
|
|
723
|
+
function duplicates(pages, valueOf, name) {
|
|
724
|
+
/** @type {Map<string, string[]>} */
|
|
725
|
+
const groups = new Map()
|
|
726
|
+
for (const page of indexedPages(pages)) {
|
|
727
|
+
const value = valueOf(page)
|
|
728
|
+
if (value) groups.set(value, [...(groups.get(value) ?? []), page.path])
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
return [...groups.values()]
|
|
732
|
+
.filter((paths) => {
|
|
733
|
+
return paths.length > 1
|
|
734
|
+
})
|
|
735
|
+
.flatMap((paths) => {
|
|
736
|
+
return paths.map((path) => {
|
|
737
|
+
const others = paths.filter((other) => {
|
|
738
|
+
return other !== path
|
|
739
|
+
})
|
|
740
|
+
const list =
|
|
741
|
+
others.length === 1
|
|
742
|
+
? others[0]
|
|
743
|
+
: `${others[0]} and ${count(others.length - 1, "other page")}`
|
|
744
|
+
return { path, message: `The ${name} is the same as on ${list}.` }
|
|
745
|
+
})
|
|
746
|
+
})
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/**
|
|
750
|
+
* The rules that compare the pages of the site. Only the build runs them, because the
|
|
751
|
+
* dev toolbar app sees one page at a time. A check returns the problems of the pages.
|
|
752
|
+
*
|
|
753
|
+
* @type {import("./rules").SiteRule[]}
|
|
754
|
+
*/
|
|
755
|
+
export const SITE_RULES = [
|
|
756
|
+
{
|
|
757
|
+
id: "title-not-unique",
|
|
758
|
+
field: "title",
|
|
759
|
+
level: "warning",
|
|
760
|
+
check(pages) {
|
|
761
|
+
return duplicates(
|
|
762
|
+
pages,
|
|
763
|
+
(page) => {
|
|
764
|
+
return page.head.titles[0]
|
|
765
|
+
},
|
|
766
|
+
"title"
|
|
767
|
+
)
|
|
768
|
+
},
|
|
769
|
+
},
|
|
770
|
+
{
|
|
771
|
+
id: "description-not-unique",
|
|
772
|
+
field: "description",
|
|
773
|
+
level: "warning",
|
|
774
|
+
check(pages) {
|
|
775
|
+
return duplicates(
|
|
776
|
+
pages,
|
|
777
|
+
(page) => {
|
|
778
|
+
return page.head.meta.description
|
|
779
|
+
},
|
|
780
|
+
"description"
|
|
781
|
+
)
|
|
782
|
+
},
|
|
783
|
+
},
|
|
784
|
+
{
|
|
785
|
+
id: "hreflang-no-return",
|
|
786
|
+
field: "hreflang",
|
|
787
|
+
level: "warning",
|
|
788
|
+
check(pages) {
|
|
789
|
+
// The pages by their canonical URL
|
|
790
|
+
const byUrl = new Map()
|
|
791
|
+
for (const page of pages) {
|
|
792
|
+
const canonical = findLink(page.head, "canonical")
|
|
793
|
+
if (canonical) byUrl.set(canonical, page)
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
return pages.flatMap((page) => {
|
|
797
|
+
const canonical = findLink(page.head, "canonical")
|
|
798
|
+
if (!canonical) return []
|
|
799
|
+
|
|
800
|
+
return hreflangLinks(page.head).flatMap((link) => {
|
|
801
|
+
const other = byUrl.get(link.href)
|
|
802
|
+
if (!other || other === page || link.hreflang.toLowerCase() === "x-default") return []
|
|
803
|
+
|
|
804
|
+
const back = hreflangLinks(other.head).some((otherLink) => {
|
|
805
|
+
return otherLink.href === canonical
|
|
806
|
+
})
|
|
807
|
+
if (back) return []
|
|
808
|
+
return [
|
|
809
|
+
{
|
|
810
|
+
path: page.path,
|
|
811
|
+
message: `The hreflang page ${other.path} does not link back to this page. Search engines ignore one-way hreflang links.`,
|
|
812
|
+
},
|
|
813
|
+
]
|
|
814
|
+
})
|
|
815
|
+
})
|
|
816
|
+
},
|
|
817
|
+
},
|
|
818
|
+
]
|
|
819
|
+
|
|
820
|
+
// The image formats that all social sites show
|
|
821
|
+
const IMAGE_TYPES = ["image/jpeg", "image/png", "image/webp"]
|
|
822
|
+
|
|
823
|
+
/** WhatsApp shows no image over this file size, in bytes. */
|
|
824
|
+
const MAX_IMAGE_BYTES = 600 * 1024
|
|
825
|
+
|
|
826
|
+
/**
|
|
827
|
+
* The rules that load the files of the page: the `og:image` and the favicons.
|
|
828
|
+
* The build reads the files from the output folder, and the dev toolbar app loads them
|
|
829
|
+
* from the dev server. A check gets the head and the loaded files.
|
|
830
|
+
*
|
|
831
|
+
* @type {import("./rules").AssetRule[]}
|
|
832
|
+
*/
|
|
833
|
+
export const ASSET_RULES = [
|
|
834
|
+
{
|
|
835
|
+
id: "og-image-not-found",
|
|
836
|
+
field: "og:image",
|
|
837
|
+
level: "error",
|
|
838
|
+
check(head, assets) {
|
|
839
|
+
const image = assets.image
|
|
840
|
+
if (image && !image.ok) {
|
|
841
|
+
const status = image.status ? `: the server answered ${image.status}` : ""
|
|
842
|
+
return `The \`og:image\` does not load${status}.`
|
|
843
|
+
}
|
|
844
|
+
},
|
|
845
|
+
},
|
|
846
|
+
{
|
|
847
|
+
id: "og-image-svg",
|
|
848
|
+
field: "og:image",
|
|
849
|
+
level: "error",
|
|
850
|
+
check(head, assets) {
|
|
851
|
+
if (assets.image?.ok && assets.image.type === "image/svg+xml") {
|
|
852
|
+
return "The `og:image` is an SVG file. Social sites do not show SVG images."
|
|
853
|
+
}
|
|
854
|
+
},
|
|
855
|
+
},
|
|
856
|
+
{
|
|
857
|
+
id: "og-image-format",
|
|
858
|
+
field: "og:image",
|
|
859
|
+
level: "warning",
|
|
860
|
+
check(head, assets) {
|
|
861
|
+
const type = assets.image?.ok ? assets.image.type : undefined
|
|
862
|
+
if (type && type !== "image/svg+xml" && !IMAGE_TYPES.includes(type)) {
|
|
863
|
+
return `The \`og:image\` is a \`${type}\` file. Use JPEG, PNG or WebP: some sites show no other formats.`
|
|
864
|
+
}
|
|
865
|
+
},
|
|
866
|
+
},
|
|
867
|
+
{
|
|
868
|
+
id: "og-image-file-large",
|
|
869
|
+
field: "og:image",
|
|
870
|
+
level: "warning",
|
|
871
|
+
check(head, assets) {
|
|
872
|
+
const bytes = assets.image?.ok ? assets.image.bytes : undefined
|
|
873
|
+
if (bytes && bytes > MAX_IMAGE_BYTES) {
|
|
874
|
+
return `The \`og:image\` file is ${Math.round(bytes / 1024)} KB. WhatsApp shows no image over 600 KB.`
|
|
875
|
+
}
|
|
876
|
+
},
|
|
877
|
+
},
|
|
878
|
+
{
|
|
879
|
+
id: "og-image-size-mismatch",
|
|
880
|
+
field: "og:image",
|
|
881
|
+
level: "warning",
|
|
882
|
+
check(head, assets) {
|
|
883
|
+
const image = assets.image
|
|
884
|
+
const size = imageSize(head)
|
|
885
|
+
if (!image?.ok || !image.width || !image.height || !size) return
|
|
886
|
+
if (image.width !== size.width || image.height !== size.height) {
|
|
887
|
+
return `The \`og:image\` is ${image.width} × ${image.height} px, but \`og:image:width\` and \`og:image:height\` give ${size.width} × ${size.height}.`
|
|
888
|
+
}
|
|
889
|
+
},
|
|
890
|
+
},
|
|
891
|
+
{
|
|
892
|
+
id: "favicon-not-found",
|
|
893
|
+
field: "favicon",
|
|
894
|
+
level: "error",
|
|
895
|
+
check(head, assets) {
|
|
896
|
+
const missing = assets.icons.find((icon) => {
|
|
897
|
+
return !icon.ok
|
|
898
|
+
})
|
|
899
|
+
if (missing) return `The favicon \`${missing.url}\` does not load.`
|
|
900
|
+
},
|
|
901
|
+
},
|
|
902
|
+
]
|